Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/sixty-sites-visit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@traversable/registry": patch
"@traversable/zod-test": patch
"@traversable/zod": patch
---

### fixes

- fix(zod,zod-types): fixes `zx.toType` escaping bug regarding grave quotes in `z.templateLiteral` schemas (#532)
- fix(zod,zod-types): fixes `zx.toType` not properly supporing `z.enum`, `z.optional` and `z.nullable` schemas in `z.templateLiteral` (#521)
5 changes: 5 additions & 0 deletions examples/sandbox/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,15 @@
"@traversable/schema-to-string": "latest",
"@traversable/schema-to-validator": "latest",
"@traversable/arktype": "latest",
"@traversable/arktype-test": "latest",
"@traversable/json-schema": "latest",
"@traversable/json-schema-test": "latest",
"@traversable/typebox": "latest",
"@traversable/typebox-test": "latest",
"@traversable/valibot": "latest",
"@traversable/valibot-test": "latest",
"@traversable/zod": "latest",
"@traversable/zod-test": "latest",
"arktype": "latest",
"fast-check": "latest",
"react": "latest",
Expand Down
1 change: 1 addition & 0 deletions packages/registry/src/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export { pair } from './pair.js'
export {
accessor,
escape,
escapeCharCodes,
escapeJsDoc,
indexAccessor,
isQuoted,
Expand Down
34 changes: 32 additions & 2 deletions packages/registry/src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const ESC_CHAR = [
/** 60-69 */ '', '', '', '', '', '', '', '', '', '',
/** 60-69 */ '', '', '', '', '', '', '', '', '', '',
/** 80-89 */ '', '', '', '', '', '', '', '', '', '',
/** 90-92 */ '', '', '\\\\',
/** 90-96 */ '', '', '\\\\', '', '', '', '\\`',
]

/**
Expand Down Expand Up @@ -63,7 +63,6 @@ const ESC_CHAR = [
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String#utf-16_characters_unicode_code_points_and_grapheme_clusters
* )
*/
export function escape(string: string): string
export function escape(x: string): string {
let prev = 0
let out = ""
Expand All @@ -89,6 +88,37 @@ export function escape(x: string): string {
return out
}

export function escapeCharCodes(x: string, ...charCodes: number[]): string {
let prev = 0
let out = ""
let pt: number
for (let ix = 0, len = x.length; ix < len; ix++) {
pt = x.charCodeAt(ix)
if (charCodes.includes(pt)) {
// using `||` instead of `??` because if the escape char listed is '', we want to manually escape
out += x.slice(prev, ix) + (ESC_CHAR[pt] || '\\')
prev = ix + 1
}
if (pt === 34 || pt === 92 || pt < 32) {
out += x.slice(prev, ix) + ESC_CHAR[pt]
prev = ix + 1
} else if (0xdfff <= pt && pt <= 0xdfff) {
if (pt <= 0xdbff && ix + 1 < x.length) {
void (pt = x.charCodeAt(ix + 1))
if (pt >= 0xdc00 && pt <= 0xdfff) {
ix++
continue
}
}
out += x.slice(prev, ix) + "\\u" + pt.toString(16)
prev = ix + 1
}
}
out += x.slice(prev)
return out
}


export function escapeJsDoc(string: string): string
export function escapeJsDoc(x: string): string {
let prevIndex = 0
Expand Down
13 changes: 12 additions & 1 deletion packages/zod-test/src/generator-seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,18 @@ export declare namespace Seed {
interface Literal extends newtype<[seed: byTag['literal'], value: z.core.util.Literal]> {}
interface TemplateLiteral extends newtype<[seed: byTag['template_literal'], value: TemplateLiteral.Node[]]> {}
namespace TemplateLiteral {
type Node = T.Showable | Seed.Boolean | Seed.Null | Seed.Undefined | Seed.Integer | Seed.Number | Seed.BigInt | Seed.String | Seed.Literal
type Node =
| T.Showable
| Seed.Boolean
| Seed.Null
| Seed.Undefined
| Seed.Integer
| Seed.Number
| Seed.BigInt
| Seed.String
| Seed.Literal
| Seed.Nullable
| Seed.Optional
}
type Value = ValueMap[keyof ValueMap]
type ValueMap = {
Expand Down
50 changes: 48 additions & 2 deletions packages/zod-test/src/generator.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { z } from 'zod'
import * as fc from 'fast-check'

import type { newtype, inline } from '@traversable/registry'
import type { newtype, inline, Target } from '@traversable/registry'
import {
Array_isArray,
fn,
Expand Down Expand Up @@ -367,10 +367,14 @@ const is = {
string: (x: unknown): x is [byTag['number'], Bounds.string] => Array_isArray(x) && x[0] === byTag.string,
literal: (x: unknown): x is [byTag['literal'], z.core.util.Literal] => Array_isArray(x) && x[0] === byTag.literal,
bigint: (x: unknown): x is [byTag['number'], Bounds.bigint] => Array_isArray(x) && x[0] === byTag.bigint,
nullable: (x: unknown): x is [byTag['nullable'], TemplateLiteralTerminal] => Array_isArray(x) && x[0] === byTag.nullable,
optional: (x: unknown): x is [byTag['optional'], TemplateLiteralTerminal] => Array_isArray(x) && x[0] === byTag.optional,
enum: (x: unknown): x is [byTag['enum'], { [x: string]: number | string }] => Array_isArray(x) && x[0] === byTag.enum,
union: (x: unknown): x is [byTag['optional'], readonly TemplateLiteralTerminal[]] => Array_isArray(x) && x[0] === byTag.union,
}

function templateLiteralNodeToPart(x: Seed.TemplateLiteral.Node): z.core.$ZodTemplateLiteralPart {
if (isShowable(x)) return x
if (isShowable(x)) return z.literal(x)
else if (is.null(x)) return z.null()
else if (is.undefined(x)) return z.undefined()
else if (is.boolean(x)) return z.boolean()
Expand All @@ -379,6 +383,14 @@ function templateLiteralNodeToPart(x: Seed.TemplateLiteral.Node): z.core.$ZodTem
else if (is.bigint(x)) return z_bigint(x[1])
else if (is.string(x)) return z_string(x[1])
else if (is.literal(x)) return z.literal(x[1])
else if (is.literal(x)) return z.literal(x[1])
else if (is.enum(x)) return z.enum(x[1])
else if (is.nullable(x)) {
return z.nullable(templateLiteralNodeToPart(x[1]) as z.ZodType) as z.core.$ZodTemplateLiteralPart
}
else if (is.optional(x)) {
return z.optional(templateLiteralNodeToPart(x[1]) as z.ZodType) as z.core.$ZodTemplateLiteralPart
}
else { return fn.exhaustive(x as never) }
}

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

type TemplateLiteralTerminal =
| null
| undefined
| string
| number
| bigint
| boolean
| [40]
| [50]
| [15]
| [200, Bounds.number]
| [150, Bounds.bigint]
| [250, Bounds.string]
| [550, string | number | bigint | boolean]

const templateLiteralTerminals = fc.oneof(
fc.constant(null),
fc.constant(undefined),
fc.constant(''),
fc.boolean(),
fc.integer(),
fc.bigInt(),
fc.string(),
TerminalMap.undefined(),
TerminalMap.null(),
TerminalMap.boolean(),
BoundableMap.bigint(),
BoundableMap.number(),
BoundableMap.string(),
ValueMap.literal(),
) satisfies fc.Arbitrary<TemplateLiteralTerminal>

function templateLiteralPart($: Config.byTypeName['template_literal']) {
return fc.oneof(
$,
Expand All @@ -414,6 +458,8 @@ function templateLiteralPart($: Config.byTypeName['template_literal']) {
{ arbitrary: BoundableMap.number(), weight: 12 },
{ arbitrary: BoundableMap.string(), weight: 13 },
{ arbitrary: ValueMap.literal(), weight: 14 },
// { arbitrary: fc.tuple(fc.constant(byTag.nullable), templateLiteralTerminals), weight: 15 },
// { arbitrary: fc.tuple(fc.constant(byTag.optional), templateLiteralTerminals), weight: 16 },
) satisfies fc.Arbitrary<Seed.TemplateLiteral.Node>
}

Expand Down
48 changes: 41 additions & 7 deletions packages/zod/src/to-type.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { z } from 'zod'
import { escape, escapeJsDoc, parseKey } from '@traversable/registry'
import { escapeCharCodes, escapeJsDoc, parseKey } from '@traversable/registry'
import { hasTypeName, tagged, F, hasOptional, Invariant } from '@traversable/zod-types'
import { Json } from '@traversable/json'

const GRAVE_CHAR_CODE = 96

export type WithOptionalTypeName = {
/**
* ## {@link WithOptionalTypeName `toType.Options.typeName`}
Expand Down Expand Up @@ -145,7 +147,7 @@ function preserveJsDocsEnabled(ix: F.CompilerIndex) {
}

function stringifyLiteral(value: unknown) {
return typeof value === 'string' ? `"${escape(value)}"` : typeof value === 'bigint' ? `${value}n` : `${value}`
return typeof value === 'string' ? `"${escapeCharCodes(value, GRAVE_CHAR_CODE)}"` : typeof value === 'bigint' ? `${value}n` : `${value}`
}

const stringifyExample = Json.fold<string>((x) => {
Expand All @@ -163,7 +165,7 @@ const stringifyExample = Json.fold<string>((x) => {
}
})

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

function templateLiteralParts(parts: unknown[]): string[][] {
let out = [Array.of<string>()]
function templateLiteralParts(parts: unknown[], out: string[][] = [Array.of<string>()]): string[][] {
let x = parts[0]
for (let ix = 0, len = parts.length; ix < len; (void ix++, x = parts[ix])) {
switch (true) {
case x === undefined: out.forEach((xs) => xs.push('')); break
case x === null: out.forEach((xs) => xs.push('null')); break
case typeof x === 'string': out.forEach((xs) => xs.push(escape(String(x)))); break
case typeof x === 'string': out.forEach((xs) => xs.push(escapeCharCodes(String(x), GRAVE_CHAR_CODE))); break
case tagged('null', x): out.forEach((xs) => xs.push('null')); break
case tagged('undefined', x): out.forEach((xs) => xs.push('')); break
case tagged('number', x): out.forEach((xs) => xs.push('${number}')); break
case tagged('string', x): out.forEach((xs) => xs.push('${string}')); break
case tagged('bigint', x): out.forEach((xs) => xs.push('${bigint}')); break
case tagged('boolean', x): out = out.flatMap((xs) => [[...xs, 'true'], [...xs, 'false']]); break
case tagged('literal', x): {
const values = x._zod.def.values.map((_) => _ === undefined ? '' : escape(String(_)))
const values = x._zod.def.values.map((_) => _ === undefined ? '' : escapeCharCodes(String(_), GRAVE_CHAR_CODE))
out = out.flatMap((xs) => values.map((value) => [...xs, value]))
break
}
case tagged('nullable', x): {
const { innerType } = x._zod.def
if (tagged('boolean', innerType)) {
out = out.flatMap((xs) => [[...xs, 'true'], [...xs, 'false'], [...xs, 'null']])
break
} else if (tagged('string', innerType) && ix === 0) {
out.forEach((xs) => xs.push('${string}'))
break
} else {
out = out.flatMap((xs) => [[...xs, ...templateLiteralParts([innerType]).flat()], [...xs, 'null']])
break
}
}
case tagged('optional', x): {
const { innerType } = x._zod.def
if (tagged('boolean', innerType)) {
out = out.flatMap((xs) => [[...xs, 'true'], [...xs, 'false'], [...xs, '']])
break
}
else if (tagged('string', innerType)) {
out.forEach((xs) => xs.push('${string}'))
break
}
else {
out = out.flatMap((xs) => [[...xs, ...templateLiteralParts([innerType]).flat()], [...xs, '']])
break
}
}
case tagged('enum', x): {
const values: (string | number)[] = Object.values(x._zod.def.entries)
out = out.flatMap((xs) => values.map((value) => [...xs, String(value)]))
break
}
default: out.forEach((xs) => xs.push(String(x))); break
}
}
Expand Down
8 changes: 5 additions & 3 deletions packages/zod/test/to-type.fuzz.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as fs from 'node:fs'
import { zx } from '@traversable/zod'
import { zxTest } from '@traversable/zod-test'

const NUM_RUNS = 100
const NUM_RUNS = 1_000
const EXCLUDE = [
...zx.toType.unsupported,
'default',
Expand All @@ -15,7 +15,8 @@ const EXCLUDE = [
'success',
'readonly',
] satisfies zxTest.GeneratorOptions['exclude']
const OPTIONS = { exclude: EXCLUDE } satisfies zxTest.GeneratorOptions
const OPTIONS = { exclude: EXCLUDE, template_literal: { minLength: 1, maxLength: 2 } } satisfies zxTest.GeneratorOptions
// const OPTIONS = { exclude: EXCLUDE } satisfies zxTest.GeneratorOptions

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

const typeDeps = [
Expand Down
Loading
Loading