Skip to content

Commit

Permalink
SchemaAST: fix TemplateLiteral model (#4076)
Browse files Browse the repository at this point in the history
  • Loading branch information
gcanti authored Dec 5, 2024
1 parent 0015724 commit 3862cd3
Show file tree
Hide file tree
Showing 12 changed files with 491 additions and 215 deletions.
25 changes: 25 additions & 0 deletions .changeset/few-emus-join.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"effect": patch
---

Schema: fix bug in `Schema.TemplateLiteralParser` resulting in a runtime error.

Before

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

const schema = Schema.TemplateLiteralParser("a", "b")
// throws TypeError: Cannot read properties of undefined (reading 'replace')
```

After

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

const schema = Schema.TemplateLiteralParser("a", "b")

console.log(Schema.decodeUnknownSync(schema)("ab"))
// Output: [ 'a', 'b' ]
```
7 changes: 7 additions & 0 deletions .changeset/great-wombats-joke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"effect": patch
---

SchemaAST: fix `TemplateLiteral` model.

Added `Literal` and `Union` as valid types.
12 changes: 12 additions & 0 deletions packages/effect/dtslint/Schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2695,6 +2695,18 @@ S.Array(S.String).pipe(S.minItems(1), S.maxItems(2))
// TemplateLiteralParser
// ---------------------------------------------

// $ExpectType Schema<readonly ["a"], "a", never>
S.asSchema(S.TemplateLiteralParser("a"))

// $ExpectType TemplateLiteralParser<["a"]>
S.TemplateLiteralParser("a")

// $ExpectType Schema<readonly ["a", "b"], "ab", never>
S.asSchema(S.TemplateLiteralParser("a", "b"))

// $ExpectType TemplateLiteralParser<["a", "b"]>
S.TemplateLiteralParser("a", "b")

// $ExpectType Schema<readonly [number, "a"], `${number}a`, never>
S.asSchema(S.TemplateLiteralParser(S.Int, "a"))

Expand Down
28 changes: 21 additions & 7 deletions packages/effect/src/Arbitrary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,15 +184,29 @@ const go = (
return (fc) => {
const string = fc.string({ maxLength: 5 })
const number = fc.float({ noDefaultInfinity: true }).filter((n) => !Number.isNaN(n))
const components: Array<FastCheck.Arbitrary<string | number>> = [fc.constant(ast.head)]
for (const span of ast.spans) {
if (AST.isStringKeyword(span.type)) {
components.push(string)
} else {
components.push(number)

const components: Array<FastCheck.Arbitrary<string | number>> = ast.head !== "" ? [fc.constant(ast.head)] : []

const addArb = (ast: AST.TemplateLiteralSpan["type"]) => {
switch (ast._tag) {
case "StringKeyword":
return components.push(string)
case "NumberKeyword":
return components.push(number)
case "Literal":
return components.push(fc.constant(String(ast.literal)))
case "Union":
return ast.types.forEach(addArb)
}
components.push(fc.constant(span.literal))
}

ast.spans.forEach((span) => {
addArb(span.type)
if (span.literal !== "") {
components.push(fc.constant(span.literal))
}
})

return fc.tuple(...components).map((spans) => spans.join(""))
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/effect/src/JSONSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,7 @@ const go = (
const regex = AST.getTemplateLiteralRegExp(ast)
return merge({
type: "string",
title: String(ast),
description: "a template literal",
pattern: regex.source
}, getJsonSchemaAnnotations(ast))
Expand Down
110 changes: 49 additions & 61 deletions packages/effect/src/Schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -708,64 +708,48 @@ type TemplateLiteralParameter = Schema.AnyNoContext | AST.LiteralValue
export const TemplateLiteral = <Params extends array_.NonEmptyReadonlyArray<TemplateLiteralParameter>>(
...[head, ...tail]: Params
): TemplateLiteral<Join<Params>> => {
let astOrs: ReadonlyArray<AST.TemplateLiteral | string> = getTemplateLiterals(
getTemplateLiteralParameterAST(head)
)
for (const span of tail) {
astOrs = array_.flatMap(
astOrs,
(a) => getTemplateLiterals(getTemplateLiteralParameterAST(span)).map((b) => combineTemplateLiterals(a, b))
)
}
return make(AST.Union.make(astOrs.map((astOr) => Predicate.isString(astOr) ? new AST.Literal(astOr) : astOr)))
}
const spans: Array<AST.TemplateLiteralSpan> = []
let h = ""
let ts = tail

const getTemplateLiteralParameterAST = (span: TemplateLiteralParameter): AST.AST =>
isSchema(span) ? span.ast : new AST.Literal(String(span))

const combineTemplateLiterals = (
a: AST.TemplateLiteral | string,
b: AST.TemplateLiteral | string
): AST.TemplateLiteral | string => {
if (Predicate.isString(a)) {
return Predicate.isString(b) ?
a + b :
new AST.TemplateLiteral(a + b.head, b.spans)
if (isSchema(head)) {
if (AST.isLiteral(head.ast)) {
h = String(head.ast.literal)
} else {
ts = [head, ...ts]
}
} else {
h = String(head)
}
if (Predicate.isString(b)) {
return new AST.TemplateLiteral(
a.head,
array_.modifyNonEmptyLast(
a.spans,
(span) => new AST.TemplateLiteralSpan(span.type, span.literal + b)
)
)

for (let i = 0; i < ts.length; i++) {
const item = ts[i]
if (isSchema(item)) {
if (i < ts.length - 1) {
const next = ts[i + 1]
if (isSchema(next)) {
if (AST.isLiteral(next.ast)) {
spans.push(new AST.TemplateLiteralSpan(item.ast, String(next.ast.literal)))
i++
continue
}
} else {
spans.push(new AST.TemplateLiteralSpan(item.ast, String(next)))
i++
continue
}
}
spans.push(new AST.TemplateLiteralSpan(item.ast, ""))
} else {
spans.push(new AST.TemplateLiteralSpan(new AST.Literal(item), ""))
}
}
return new AST.TemplateLiteral(
a.head,
array_.appendAll(
array_.modifyNonEmptyLast(
a.spans,
(span) => new AST.TemplateLiteralSpan(span.type, span.literal + String(b.head))
),
b.spans
)
)
}

const getTemplateLiterals = (
ast: AST.AST
): ReadonlyArray<AST.TemplateLiteral | string> => {
switch (ast._tag) {
case "Literal":
return [String(ast.literal)]
case "NumberKeyword":
case "StringKeyword":
return [new AST.TemplateLiteral("", [new AST.TemplateLiteralSpan(ast, "")])]
case "Union":
return array_.flatMap(ast.types, getTemplateLiterals)
if (array_.isNonEmptyArray(spans)) {
return make(new AST.TemplateLiteral(h, spans))
} else {
return make(new AST.TemplateLiteral("", [new AST.TemplateLiteralSpan(new AST.Literal(h), "")]))
}
throw new Error(errors_.getSchemaUnsupportedLiteralSpanErrorMessage(ast))
}

type TemplateLiteralParserParameters = Schema.Any | AST.LiteralValue
Expand Down Expand Up @@ -821,17 +805,21 @@ export const TemplateLiteralParser = <Params extends array_.NonEmptyReadonlyArra
}
const from = TemplateLiteral(...encodedSchemas as any)
const re = AST.getTemplateLiteralCapturingRegExp(from.ast as AST.TemplateLiteral)
return class TemplateLiteralParserClass extends transform(from, Tuple(...typeSchemas), {
return class TemplateLiteralParserClass extends transformOrFail(from, Tuple(...typeSchemas), {
strict: false,
decode: (s) => {
const out: Array<number | string> = re.exec(s)!.slice(1, params.length + 1)
for (let i = 0; i < numbers.length; i++) {
const index = numbers[i]
out[index] = Number(out[index])
decode: (s, _, ast) => {
const match = re.exec(s)
if (match) {
const out: Array<number | string> = match.slice(1, params.length + 1)
for (let i = 0; i < numbers.length; i++) {
const index = numbers[i]
out[index] = Number(out[index])
}
return ParseResult.succeed(out)
}
return out
return ParseResult.fail(new ParseResult.Type(ast, s, `${re.source}: no match for ${JSON.stringify(s)}`))
},
encode: (tuple) => tuple.join("")
encode: (tuple) => ParseResult.succeed(tuple.join(""))
}) {
static params = params.slice()
} as any
Expand Down
Loading

0 comments on commit 3862cd3

Please sign in to comment.