Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
105 changes: 105 additions & 0 deletions ark/type/__tests__/objects/defaults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,111 @@ contextualize(() => {
.type.errors("Default value [] must be assignable to number")
})

it("non-empty array default via string syntax", () => {
const Hosts = type({
HOSTS: "string[] = ['localhost']"
})

attest(Hosts.t).type.toString.snap(
'{ HOSTS: Default<string[], ["localhost"]> }'
)
attest<{ HOSTS?: string[] }>(Hosts.inferIn)
attest<{ HOSTS: string[] }>(Hosts.infer)

attest(Hosts.assert({}).HOSTS).equals(["localhost"])
attest(Hosts({ HOSTS: ["127.0.0.1"] })).equals({
HOSTS: ["127.0.0.1"]
})
})

it("non-empty array default accepts either quote style", () => {
const single = type({ HOSTS: "string[] = ['localhost']" })
const double = type({ HOSTS: 'string[] = ["localhost"]' })

attest(single.assert({}).HOSTS).equals(["localhost"])
attest(double.assert({}).HOSTS).equals(["localhost"])
attest(double.t).type.toString.snap(
'{ HOSTS: Default<string[], ["localhost"]> }'
)
})

it("non-empty array default returns a fresh array each call", () => {
const O = type({ values: "string[] = ['localhost']" })
const a = O.assert({})
const b = O.assert({})
attest(a.values !== b.values)
a.values.push("example.com")
attest(b.values).equals(["localhost"])
attest(O.assert({}).values).equals(["localhost"])
})

it("non-empty array default of other unit literals", () => {
const Ports = type({ ports: "number[] = [3000, 8080]" })
attest(Ports.t).type.toString.snap(
"{ ports: Default<number[], [3000, 8080]> }"
)
attest(Ports.assert({}).ports).equals([3000, 8080])

const Flags = type({ flags: "boolean[] = [true, false]" })
attest(Flags.assert({}).flags).equals([true, false])
})

it("non-empty array default allows whitespace and a trailing comma", () => {
const O = type({ values: "string[] = [ 'localhost' , '127.0.0.1', ]" })
attest(O.assert({}).values).equals(["localhost", "127.0.0.1"])
attest(O.t).type.toString.snap(`{
values: Default<string[], ["localhost", "127.0.0.1"]>
}`)
})

it("non-empty array default rejects a non-literal element", () => {
attest(() =>
type({
// @ts-expect-error
values: "string[] = [string]"
})
)
.throws(writeNonLiteralDefaultMessage("[string]"))
.type.errors(writeNonLiteralDefaultMessage("[string]"))
})

it("non-empty array default rejects an unassignable element", () => {
// @ts-expect-error
attest(() => type({ values: "string[] = [1]" }))
.throws.snap(
"ParseError: Default for values[0] must be a string (was a number)"
)
.type.errors("Default value [1] must be assignable to string[]")
})

it("non-empty array default rejects a non-array base", () => {
// @ts-expect-error
attest(() => type({ foo: "number = ['localhost']" }))
.throws.snap(
"ParseError: Default for foo must be a number (was an object)"
)
.type.errors("Default value ['localhost'] must be assignable to number")
})

it("non-empty array default keeps commas inside strings", () => {
const O = type({ values: "string[] = ['a,b', \"c\"]" })
attest(O.assert({}).values).equals(["a,b", "c"])
attest(O.t).type.toString.snap(
'{ values: Default<string[], ["a,b", "c"]> }'
)
})

it("non-empty array default rejects trailing junk", () => {
attest(() =>
type({
// @ts-expect-error
values: "string[] = [1]x"
})
)
.throws(writeUnexpectedCharacterMessage("x"))
.type.errors(writeNonLiteralDefaultMessage("[1]x"))
})

it("empty object default via string syntax", () => {
const O = type({ meta: "object = {}" })

Expand Down
220 changes: 214 additions & 6 deletions ark/type/parser/shift/operator/default.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { BaseRoot } from "@ark/schema"
import {
keysOf,
tryParseWellFormedBigint,
tryParseWellFormedNumber,
whitespaceChars,
type BigintLiteral,
type ErrorMessage,
type NumberLiteral,
Expand All @@ -10,10 +13,12 @@ import {
import type { type } from "../../../keywords/keywords.ts"
import type { DateLiteral } from "../../../attributes.ts"
import type { RootedRuntimeState } from "../../reduce/dynamic.ts"
import type {
EnclosingLiteralStartToken,
EnclosingLiteralTokens,
StringLiteral
import {
parseEnclosed,
type EnclosingLiteralStartToken,
type EnclosingLiteralTokens,
type EnclosingStartToken,
type StringLiteral
} from "../operand/enclosed.ts"

type UnitLiteralKeyword = "null" | "undefined" | "true" | "false"
Expand All @@ -34,7 +39,16 @@ export type EnclosedUnitLiteral = StringLiteral | DateLiteral
*/
export type EmptyCollectionLiteral = "[]" | "{}"

export type DefaultLiteral = UnitLiteral | EmptyCollectionLiteral
/**
* A `[...]` default whose elements are unit literals. Like `[]`, the runtime
* value is a thunk so each traversal receives a fresh array.
*/
export type UnitLiteralArrayLiteral = `[${string}]`

export type DefaultLiteral =
| UnitLiteral
| EmptyCollectionLiteral
| UnitLiteralArrayLiteral

const emptyCollectionDefaults = {
"[]": () => [],
Expand All @@ -52,6 +66,7 @@ const emptyCollectionLiterals = keysOf(emptyCollectionDefaults)
export type inferDefaultLiteral<literal> =
literal extends "[]" ? []
: literal extends "{}" ? {}
: literal extends UnitLiteralArrayLiteral ? inferUnitLiteralArray<literal>
: type.infer<literal>

export type ParsedDefaultableProperty = readonly [BaseRoot, "=", unknown]
Expand All @@ -71,6 +86,17 @@ export const parseDefault = (
s.scanner.jumpForward(emptyCollection.length)
return [baseNode, "=", emptyCollectionDefaults[emptyCollection]]
}
// a non-empty list (or `[ ]`) is not a unit literal. `[` would otherwise
// fail as a missing expression, so parse unit elements here and thunk the
// result the same way `[]` does.
if (s.scanner.lookahead === "[") {
const elements = parseUnitLiteralArray(s)
return [
baseNode,
"=",
() => elements.map(element => cloneDefaultElement(element))
]
}
s.parseOperand()
const defaultNode = s.unsetRoot()
// after parsing the next operand, use the locations to get the
Expand All @@ -87,7 +113,13 @@ export const parseDefault = (
export type parseDefault<root, unscanned extends string> =
// default values must always appear at the end of a string definition,
// so parse the rest of the string and ensure it is a valid unit literal
trim<unscanned> extends infer defaultExpression extends string ?
// or a unit-literal array. skipWhitespace (not trim) so `[ 1 , 2 ]` is
// recognized even when more than one space precedes it.
Scanner.skipWhitespace<unscanned> extends `[${infer afterOpen}` ?
parseUnitLiteralArray<afterOpen> extends ParsedUnitLiteralArray ?
[root, "=", UnitLiteralArrayLiteral & `[${afterOpen}`]
Comment thread
yamcodes marked this conversation as resolved.
Outdated
: ErrorMessage<writeNonLiteralDefaultMessage<`[${afterOpen}`>>
: trim<unscanned> extends infer defaultExpression extends string ?
defaultExpression extends UnenclosedUnitLiteral | EmptyCollectionLiteral ?
[root, "=", defaultExpression]
: defaultExpression extends (
Expand All @@ -101,6 +133,182 @@ export type parseDefault<root, unscanned extends string> =
: ErrorMessage<writeNonLiteralDefaultMessage<defaultExpression>>
: never

const cloneDefaultElement = (element: unknown): unknown =>
element instanceof Date ? new Date(element) : element

const parseUnitLiteralArray = (s: RootedRuntimeState): unknown[] => {
const arrayStart = s.scanner.location
// consume "["
s.scanner.shift()
const elements: unknown[] = []
while (true) {
s.scanner.shiftUntilNonWhitespace()
if (s.scanner.lookahead === "]") {
s.scanner.shift()
return elements
}
if (elements.length !== 0 && s.scanner.lookahead === "")
return failUnitLiteralArray(s, arrayStart)
elements.push(parseUnitLiteralElement(s, arrayStart))
s.scanner.shiftUntilNonWhitespace()
if (s.scanner.lookahead === ",") {
s.scanner.shift()
continue
}
if (s.scanner.lookahead === "]") {
s.scanner.shift()
return elements
}
return failUnitLiteralArray(s, arrayStart)
}
}

const failUnitLiteralArray = (
s: RootedRuntimeState,
arrayStart: number
): never =>
s.error(
writeNonLiteralDefaultMessage(s.scanner.sliceChars(arrayStart).trim())
)

const enclosedUnit = (
s: RootedRuntimeState,
enclosing: EnclosingStartToken,
arrayStart: number
): unknown => {
parseEnclosed(s, enclosing)
const node = s.unsetRoot()
if (!node?.hasKind("unit")) return failUnitLiteralArray(s, arrayStart)
return node.unit
}

const parseUnitLiteralElement = (
s: RootedRuntimeState,
arrayStart: number
): unknown => {
const lookahead = s.scanner.lookahead
if (lookahead === "'" || lookahead === '"') {
s.scanner.shift()
return enclosedUnit(s, lookahead, arrayStart)
}
const quote = s.scanner.nextLookahead
if (lookahead === "d" && (quote === "'" || quote === '"')) {
s.scanner.shift()
s.scanner.shift()
return enclosedUnit(s, `d${quote}`, arrayStart)
}
const token = s.scanner.shiftUntil(
scanner =>
scanner.lookahead === "," ||
scanner.lookahead === "]" ||
scanner.lookahead in whitespaceChars
)
if (token === "null") return null
if (token === "undefined") return undefined
if (token === "true") return true
if (token === "false") return false
if (token === "") return failUnitLiteralArray(s, arrayStart)
const maybeNumber = tryParseWellFormedNumber(token)
if (maybeNumber !== undefined) return maybeNumber
const maybeBigint = tryParseWellFormedBigint(token)
if (maybeBigint !== undefined) return maybeBigint
return failUnitLiteralArray(s, arrayStart)
}

type ParsedUnitElement<value, rest extends string> = {
value: value
rest: rest
}

type ParsedUnitLiteralArray<
elements extends readonly unknown[] = readonly unknown[]
> = {
elements: elements
}

type FailedUnitParse = { error: true }

type inferEnclosedElement<
start extends EnclosingLiteralStartToken,
scanned extends string
> =
start extends "'" | '"' ? scanned
: start extends `d${"'" | '"'}` ? Date
: never

type inferUnenclosedElement<token extends string> =
token extends "null" ? null
: token extends "undefined" ? undefined
: token extends "true" ? true
: token extends "false" ? false
: token extends NumberLiteral<infer n> ?
number extends n ?
never
: n
Comment thread
yamcodes marked this conversation as resolved.
Outdated
: token extends BigintLiteral<infer b> ?
bigint extends b ?
never
: b
: never

type parseUnitElement<unscanned extends string> =
unscanned extends (
`${infer start extends EnclosingLiteralStartToken}${infer next}`
Comment thread
yamcodes marked this conversation as resolved.
Outdated
) ?
Scanner.shiftUntilEscapable<
next,
EnclosingLiteralTokens[start],
""
> extends (
Scanner.shiftResult<infer scanned extends string, infer nextUnscanned>
) ?
nextUnscanned extends `${EnclosingLiteralTokens[start]}${infer rest}` ?
inferEnclosedElement<start, scanned> extends infer value ?
[value] extends [never] ?
FailedUnitParse
: ParsedUnitElement<value, rest>
: FailedUnitParse
: FailedUnitParse
: FailedUnitParse
: Scanner.shiftUntil<unscanned, "," | "]" | " " | "\n" | "\t"> extends (
Scanner.shiftResult<infer token extends string, infer rest>
) ?
inferUnenclosedElement<token> extends infer value ?
[value] extends [never] ?
FailedUnitParse
: ParsedUnitElement<value, rest>
: FailedUnitParse
: FailedUnitParse

type parseUnitLiteralArray<
unscanned extends string,
acc extends readonly unknown[] = []
> =
Scanner.skipWhitespace<unscanned> extends `]${infer rest}` ?
Scanner.skipWhitespace<rest> extends "" ?
ParsedUnitLiteralArray<acc>
: FailedUnitParse
: parseUnitElement<Scanner.skipWhitespace<unscanned>> extends (
ParsedUnitElement<infer value, infer rest>
) ?
Scanner.skipWhitespace<rest> extends `,${infer afterComma}` ?
parseUnitLiteralArray<afterComma, [...acc, value]>
: Scanner.skipWhitespace<rest> extends `]${infer afterClose}` ?
Scanner.skipWhitespace<afterClose> extends "" ?
ParsedUnitLiteralArray<[...acc, value]>
: FailedUnitParse
: FailedUnitParse
: FailedUnitParse

type inferUnitLiteralArray<literal extends UnitLiteralArrayLiteral> =
literal extends `[${infer afterOpen}` ?
parseUnitLiteralArray<afterOpen> extends (
ParsedUnitLiteralArray<infer elements>
) ?
elements
: never
: never

export type isValidEnclosedLiteral<
start extends EnclosingLiteralStartToken,
unscanned extends string
Expand Down
Loading