diff --git a/src/dessert/deserialization/deserialize.js b/src/dessert/deserialization/deserialize.js index 6d36329..9e691eb 100644 --- a/src/dessert/deserialization/deserialize.js +++ b/src/dessert/deserialization/deserialize.js @@ -245,6 +245,38 @@ function makeArgument(state, includeTag) { return extractor(arg); }; + // @ts-expect-error Mapped return types + type inference run into limitations + getArgument.pattern = (...patterns) => { + const arg = state.arguments[0]; + if (arg === undefined) { + if (!required) { + return undefined; + } + + throw new KdlDeserializeError(`Missing argument`, { + location: state.node, + }); + } + + const value = arg.getValue(); + if ( + typeof value !== "string" || + !patterns.some((pattern) => pattern.test(value)) + ) { + if (ignoreInvalid) { + return undefined; + } + + throw new KdlDeserializeError( + `Expected a string matching ${joinWithOr(patterns.map((v) => String(v)))} but got ${JSON.stringify(arg.getValue())}`, + {location: arg}, + ); + } + + state.arguments.shift(); + return extractor(arg); + }; + let restBuilder; Object.defineProperty(getArgument, "rest", { get() { @@ -338,6 +370,37 @@ function makeArgument(state, includeTag) { }); }; + // @ts-expect-error Mapped return types + type inference run into limitations + getArgument.pattern = (...patterns) => { + if (required && state.arguments.length === 0) { + throw new KdlDeserializeError(`Missing argument`, { + location: state.node, + }); + } + + return state.arguments + .splice(0, state.arguments.length) + .flatMap((arg) => { + const value = arg.getValue(); + if ( + typeof value !== "string" || + !patterns.some((pattern) => pattern.test(value)) + ) { + if (ignoreInvalid) { + state.arguments.push(arg); + return []; + } + + throw new KdlDeserializeError( + `Expected a string matching ${joinWithOr(patterns.map((v) => String(v)))} but got ${JSON.stringify(arg.getValue())}`, + {location: arg}, + ); + } + + return [extractor(arg)]; + }); + }; + if (!required) { let requiredBuilder; Object.defineProperty(getArgument, "required", { @@ -443,6 +506,38 @@ function makeProperty(state, includeTag) { return extractor(prop); }; + // @ts-expect-error Mapped return types + type inference run into limitations + getProperty.pattern = (name, ...patterns) => { + const prop = state.properties.get(name); + if (prop === undefined) { + if (!required) { + return prop; + } + + throw new KdlDeserializeError(`Missing property ${name}`, { + location: state.node, + }); + } + + const value = prop.getValue(); + if ( + typeof value !== "string" || + !patterns.some((pattern) => pattern.test(value)) + ) { + if (ignoreInvalid) { + return undefined; + } + + throw new KdlDeserializeError( + `Expected property ${name} to match ${joinWithOr(patterns.map((v) => String(v)))} but got ${JSON.stringify(prop.getValue())}`, + {location: prop}, + ); + } + + state.properties.delete(name); + return extractor(prop); + }; + let restBuilder; Object.defineProperty(getProperty, "rest", { get() { @@ -543,6 +638,44 @@ function makeProperty(state, includeTag) { ); }; + getProperty.pattern = (...patterns) => { + if (required && state.properties.size === 0) { + throw new KdlDeserializeError(`Missing properties`, { + location: state.node, + }); + } + + const result = [...state.properties]; + state.properties.clear(); + + return new Map( + result.flatMap(([name, prop]) => { + const value = prop.getValue(); + if ( + typeof value !== "string" || + !patterns.some((pattern) => pattern.test(value)) + ) { + if (ignoreInvalid) { + state.properties.set(name, prop); + return []; + } + + throw new KdlDeserializeError( + `Expected property ${name} to match ${joinWithOr(patterns.map((v) => String(v)))} but got ${JSON.stringify(prop.getValue())}`, + {location: prop}, + ); + } + + return [ + [ + name, + /** @type {t.Tagged} */ (extractor(prop)), + ], + ]; + }), + ); + }; + if (!required) { let requiredBuilder; Object.defineProperty(getProperty, "required", { diff --git a/src/dessert/deserialization/types.d.ts b/src/dessert/deserialization/types.d.ts index 2a99f24..93c6306 100644 --- a/src/dessert/deserialization/types.d.ts +++ b/src/dessert/deserialization/types.d.ts @@ -52,8 +52,7 @@ export interface DeserializerFromNode { * Parameters can be passed via `deserialize` or any of the `child`/`children` functions on a {@link DeserializationContext} */ export type Deserializer = - | DeserializerFromContext - | DeserializerFromNode; + DeserializerFromContext | DeserializerFromNode; /** * Helper type to extract the type a deserializer supports @@ -89,6 +88,12 @@ export type ArgumentReturnType< export type Tagged = IncludeTag extends true ? [T, string | null] : T; +export interface RegExpLike { + test(text: string): boolean; + + toString(): string; +} + export interface Argument< IncludeTag extends boolean, Required extends boolean = false, @@ -132,6 +137,20 @@ export interface Argument< IgnoreInvalid >; + /** + * Return the next argument, if there is a next argument, requiring the argument is a string that matches one of the given patterns + * + * @throws If the next argument does not match any of the given patterns + */ + pattern( + ...patterns: [RegExpLike, ...RegExpLike[]] + ): ArgumentReturnType< + Tagged, + Required, + ReturnMultiple, + IgnoreInvalid + >; + /** * Throw if there is no next argument, rather than return undefined */ @@ -214,6 +233,21 @@ export interface Property< IgnoreInvalid >; + /** + * Return the property with the given name if it exists and it is a string that matches one of the given patterns + * + * @throws If the property value does not match any of the given patterns + */ + pattern( + name: string, + ...patterns: [RegExpLike, ...RegExpLike[]] + ): PropertyReturnType< + Tagged, + Required, + false, + IgnoreInvalid + >; + /** * Throw if there is no property with the given name, rather than return undefined */ @@ -270,6 +304,20 @@ export interface RestProperty< IgnoreInvalid >; + /** + * Return all remaining properties + * + * @throws If any remaining property value does not match any of the given patterns + */ + pattern( + ...patterns: [RegExpLike, ...RegExpLike[]] + ): PropertyReturnType< + Tagged, + Required, + true, + IgnoreInvalid + >; + /** * Throw if there is no property with the given name, rather than return undefined */ diff --git a/test/dessert/deserialize.js b/test/dessert/deserialize.js index fe62a6d..d9581d0 100644 --- a/test/dessert/deserialize.js +++ b/test/dessert/deserialize.js @@ -252,4 +252,22 @@ test("tagged", () => { } }); +test("pattern", () => { + const node = parse(`node "2026-08-10T22:40:03Z"`, {as: "node"}); + + assert.equal( + deserialize(node, (c) => + c.argument.required.pattern(/^\d{4}-\d{2}-\d{2}T/), + ), + "2026-08-10T22:40:03Z", + ); + + assert.equal( + deserialize(node, (c) => + c.argument.required.pattern(/lorem/, /ipsum/, /^\d{4}-\d{2}-\d{2}T/), + ), + "2026-08-10T22:40:03Z", + ); +}); + test.run();