Skip to content

Commit

Permalink
Add support for TemplateLiteral parameters in TemplateLiteral, cl… (
Browse files Browse the repository at this point in the history
  • Loading branch information
gcanti authored Dec 19, 2024
1 parent 8f81334 commit 1ce703b
Show file tree
Hide file tree
Showing 7 changed files with 568 additions and 180 deletions.
14 changes: 13 additions & 1 deletion .changeset/good-pigs-roll.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,16 @@
"effect": patch
---

Support template literals in Schema.Config
Schema: Support template literals in `Schema.Config`.

**Example**

```ts
import { Schema } from "effect"

// const config: Config<`a${string}`>
const config = Schema.Config(
"A",
Schema.TemplateLiteral(Schema.Literal("a"), Schema.String)
)
```
40 changes: 40 additions & 0 deletions .changeset/rude-impalas-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
"effect": patch
---

Schema: Add support for `TemplateLiteral` parameters in `TemplateLiteral`, closes #4166.

This update also adds support for `TemplateLiteral` and `TemplateLiteralParser` parameters in `TemplateLiteralParser`.

Before

```ts
import { Schema } from "effect"

const schema = Schema.TemplateLiteralParser(
"<",
Schema.TemplateLiteralParser("h", Schema.Literal(1, 2)),
">"
)
/*
throws:
Error: Unsupported template literal span
schema (TemplateLiteral): `h${"1" | "2"}`
*/
```

After

```ts
import { Schema } from "effect"

// Schema<readonly ["<", readonly ["h", 2 | 1], ">"], "<h2>" | "<h1>", never>
const schema = Schema.TemplateLiteralParser(
"<",
Schema.TemplateLiteralParser("h", Schema.Literal(1, 2)),
">"
)

console.log(Schema.decodeUnknownSync(schema)("<h1>"))
// Output: [ '<', [ 'h', 1 ], '>' ]
```
47 changes: 47 additions & 0 deletions .changeset/shy-carpets-tap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
"effect": patch
---

Schema: Fix bug in `TemplateLiteralParser` where unions of numeric literals were not coerced correctly.

Before

```ts
import { Schema } from "effect"

const schema = Schema.TemplateLiteralParser("a", Schema.Literal(1, 2))

console.log(Schema.decodeUnknownSync(schema)("a1"))
/*
throws:
ParseError: (`a${"1" | "2"}` <-> readonly ["a", 1 | 2])
└─ Type side transformation failure
└─ readonly ["a", 1 | 2]
└─ [1]
└─ 1 | 2
├─ Expected 1, actual "1"
└─ Expected 2, actual "1"
*/
```

After

```ts
import { Schema } from "effect"

const schema = Schema.TemplateLiteralParser("a", Schema.Literal(1, 2))

console.log(Schema.decodeUnknownSync(schema)("a1"))
// Output: [ 'a', 1 ]

console.log(Schema.decodeUnknownSync(schema)("a2"))
// Output: [ 'a', 2 ]

console.log(Schema.decodeUnknownSync(schema)("a3"))
/*
throws:
ParseError: (`a${"1" | "2"}` <-> readonly ["a", 1 | 2])
└─ Encoded side transformation failure
└─ Expected `a${"1" | "2"}`, actual "a3"
*/
```
89 changes: 52 additions & 37 deletions packages/effect/src/Schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -793,10 +793,38 @@ export interface TemplateLiteralParser<Params extends array_.NonEmptyReadonlyArr
readonly params: Params
}

const literalValueCoercions: Record<string, ((v: AST.LiteralValue) => AST.LiteralValue)> = {
bigint: (v: AST.LiteralValue) => Predicate.isString(v) ? BigInt(v) : v,
boolean: (v: AST.LiteralValue) => v === "true" ? true : v === "false" ? false : v,
null: (v: AST.LiteralValue) => v === "null" ? null : v
function getTemplateLiteralParserCoercedElement(encoded: Schema.Any, schema: Schema.Any): Schema.Any | undefined {
const ast = encoded.ast
switch (ast._tag) {
case "Literal": {
const literal = ast.literal
if (!Predicate.isString(literal)) {
const s = String(literal)
return transform(Literal(s), schema, {
strict: true,
decode: () => literal,
encode: () => s
})
}
break
}
case "NumberKeyword":
return compose(NumberFromString, schema)
case "Union": {
const members: Array<Schema.Any> = []
let hasCoercions = false
for (const member of ast.types) {
const schema = make(member)
const encoded = encodedSchema(schema)
const coerced = getTemplateLiteralParserCoercedElement(encoded, schema)
if (coerced) {
hasCoercions = true
}
members.push(coerced ?? schema)
}
return hasCoercions ? compose(Union(...members), schema) : schema
}
}
}

/**
Expand All @@ -807,49 +835,36 @@ export const TemplateLiteralParser = <Params extends array_.NonEmptyReadonlyArra
...params: Params
): TemplateLiteralParser<Params> => {
const encodedSchemas: Array<Schema.Any> = []
const typeSchemas: Array<Schema.Any> = []
const coercions: Record<number, ((v: AST.LiteralValue) => AST.LiteralValue) | undefined> = {}
const elements: Array<Schema.Any> = []
const schemas: Array<Schema.Any> = []
let coerced = false
for (let i = 0; i < params.length; i++) {
const param = params[i]
if (isSchema(param)) {
const encoded = encodedSchema(param)
if (AST.isNumberKeyword(encoded.ast)) {
coercions[i] = Number
}
encodedSchemas.push(encoded)
typeSchemas.push(param)
const schema = isSchema(param) ? param : Literal(param)
schemas.push(schema)
const encoded = encodedSchema(schema)
encodedSchemas.push(encoded)
const element = getTemplateLiteralParserCoercedElement(encoded, schema)
if (element) {
elements.push(element)
coerced = true
} else {
const schema = Literal(param)
if (Predicate.isNumber(param)) {
coercions[i] = Number
} else if (Predicate.isBigInt(param)) {
coercions[i] = literalValueCoercions.bigint
} else if (Predicate.isBoolean(param)) {
coercions[i] = literalValueCoercions.boolean
} else if (Predicate.isNull(param)) {
coercions[i] = literalValueCoercions.null
}
encodedSchemas.push(schema)
typeSchemas.push(schema)
elements.push(schema)
}
}
const from = TemplateLiteral(...encodedSchemas as any)
const re = AST.getTemplateLiteralCapturingRegExp(from.ast as AST.TemplateLiteral)
return class TemplateLiteralParserClass extends transformOrFail(from, Tuple(...typeSchemas), {
let to = Tuple(...elements)
if (coerced) {
to = to.annotations({ [AST.AutoTitleAnnotationId]: format(Tuple(...schemas)) })
}
return class TemplateLiteralParserClass extends transformOrFail(from, to, {
strict: false,
decode: (s, _, ast) => {
const match = re.exec(s)
if (match) {
const out: Array<AST.LiteralValue> = match.slice(1, params.length + 1)
for (let i = 0; i < out.length; i++) {
const coerce = coercions[i]
if (coerce) {
out[i] = coerce(out[i])
}
}
return ParseResult.succeed(out)
}
return ParseResult.fail(new ParseResult.Type(ast, s, `${re.source}: no match for ${JSON.stringify(s)}`))
return match
? ParseResult.succeed(match.slice(1, params.length + 1))
: ParseResult.fail(new ParseResult.Type(ast, s, `${re.source}: no match for ${JSON.stringify(s)}`))
},
encode: (tuple) => ParseResult.succeed(tuple.join(""))
}) {
Expand Down
96 changes: 52 additions & 44 deletions packages/effect/src/SchemaAST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1088,7 +1088,7 @@ export class Enums implements Annotated {
*/
export const isEnums: (ast: AST) => ast is Enums = createASTGuard("Enums")

type TemplateLiteralSpanBaseType = StringKeyword | NumberKeyword | Literal
type TemplateLiteralSpanBaseType = StringKeyword | NumberKeyword | Literal | TemplateLiteral

type TemplateLiteralSpanType = TemplateLiteralSpanBaseType | Union<TemplateLiteralSpanType>

Expand All @@ -1097,6 +1097,7 @@ const isTemplateLiteralSpanType = (ast: AST): ast is TemplateLiteralSpanType =>
case "Literal":
case "NumberKeyword":
case "StringKeyword":
case "TemplateLiteral":
return true
case "Union":
return ast.types.every(isTemplateLiteralSpanType)
Expand All @@ -1112,6 +1113,8 @@ const templateLiteralSpanUnionTypeToString = (type: TemplateLiteralSpanType): st
return "string"
case "NumberKeyword":
return "number"
case "TemplateLiteral":
return String(type)
case "Union":
return type.types.map(templateLiteralSpanUnionTypeToString).join(" | ")
}
Expand All @@ -1125,6 +1128,8 @@ const templateLiteralSpanTypeToString = (type: TemplateLiteralSpanType): string
return "${string}"
case "NumberKeyword":
return "${number}"
case "TemplateLiteral":
return "${" + String(type) + "}"
case "Union":
return "${" + type.types.map(templateLiteralSpanUnionTypeToString).join(" | ") + "}"
}
Expand Down Expand Up @@ -2085,72 +2090,75 @@ export const keyof = (ast: AST): AST => Union.unify(_keyof(ast))
const STRING_KEYWORD_PATTERN = ".*"
const NUMBER_KEYWORD_PATTERN = "[+-]?\\d*\\.?\\d+(?:[Ee][+-]?\\d+)?"

const getTemplateLiteralPattern = (type: TemplateLiteralSpanType): string => {
const getTemplateLiteralSpanTypePattern = (type: TemplateLiteralSpanType, capture: boolean): string => {
switch (type._tag) {
case "Literal":
return regexp.escape(String(type.literal))
case "StringKeyword":
return STRING_KEYWORD_PATTERN
case "NumberKeyword":
return NUMBER_KEYWORD_PATTERN
case "Literal":
return regexp.escape(String(type.literal))
case "TemplateLiteral":
return getTemplateLiteralPattern(type, capture, false)
case "Union":
return type.types.map(getTemplateLiteralPattern).join("|")
return type.types.map((type) => getTemplateLiteralSpanTypePattern(type, capture)).join("|")
}
}

/**
* @since 3.10.0
*/
export const getTemplateLiteralRegExp = (ast: TemplateLiteral): RegExp => {
let pattern = `^`
const handleTemplateLiteralSpanTypeParens = (
type: TemplateLiteralSpanType,
s: string,
capture: boolean,
top: boolean
) => {
if (isUnion(type)) {
if (capture && !top) {
return `(?:${s})`
}
} else if (!capture || !top) {
return s
}
return `(${s})`
}

const getTemplateLiteralPattern = (ast: TemplateLiteral, capture: boolean, top: boolean): string => {
let pattern = ``
if (ast.head !== "") {
pattern += regexp.escape(ast.head)
const head = regexp.escape(ast.head)
pattern += capture && top ? `(${head})` : head
}

for (const span of ast.spans) {
const p = getTemplateLiteralPattern(span.type)
pattern += isUnion(span.type) ? `(${p})` : p
const spanPattern = getTemplateLiteralSpanTypePattern(span.type, capture)
pattern += handleTemplateLiteralSpanTypeParens(span.type, spanPattern, capture, top)
if (span.literal !== "") {
pattern += regexp.escape(span.literal)
const literal = regexp.escape(span.literal)
pattern += capture && top ? `(${literal})` : literal
}
}

pattern += "$"
return new RegExp(pattern)
}

const getTemplateLiteralCapturingPattern = (type: TemplateLiteralSpanType): string => {
switch (type._tag) {
case "StringKeyword":
return STRING_KEYWORD_PATTERN
case "NumberKeyword":
return NUMBER_KEYWORD_PATTERN
case "Literal":
return regexp.escape(String(type.literal))
case "Union":
return type.types.map(getTemplateLiteralCapturingPattern).join("|")
}
return pattern
}

/**
* Generates a regular expression from a `TemplateLiteral` AST node.
*
* @see {@link getTemplateLiteralCapturingRegExp} for a variant that captures the pattern.
*
* @since 3.10.0
*/
export const getTemplateLiteralCapturingRegExp = (ast: TemplateLiteral): RegExp => {
let pattern = `^`
if (ast.head !== "") {
pattern += `(${regexp.escape(ast.head)})`
}

for (const span of ast.spans) {
pattern += `(${getTemplateLiteralCapturingPattern(span.type)})`
if (span.literal !== "") {
pattern += `(${regexp.escape(span.literal)})`
}
}
export const getTemplateLiteralRegExp = (ast: TemplateLiteral): RegExp =>
new RegExp(`^${getTemplateLiteralPattern(ast, false, true)}$`)

pattern += "$"
return new RegExp(pattern)
}
/**
* Generates a regular expression that captures the pattern defined by the given `TemplateLiteral` AST.
*
* @see {@link getTemplateLiteralRegExp} for a variant that does not capture the pattern.
*
* @since 3.10.0
*/
export const getTemplateLiteralCapturingRegExp = (ast: TemplateLiteral): RegExp =>
new RegExp(`^${getTemplateLiteralPattern(ast, true, true)}$`)

/**
* @since 3.10.0
Expand Down
Loading

0 comments on commit 1ce703b

Please sign in to comment.