From 85ec09d2ca70953c9c7826ef3dee4054fcc5bc12 Mon Sep 17 00:00:00 2001 From: Alessandro Bollini Date: Fri, 12 Dec 2025 14:10:59 +0100 Subject: [PATCH] Close #1699: support regex flags in tags.Pattern --- src/factories/MetadataCommentTagFactory.ts | 63 ++++++++++++++----- src/programmers/RandomProgrammer.ts | 25 ++++++-- src/tags/Pattern.ts | 32 ++++++++-- test/src/structures/CommentTagPatternFlags.ts | 36 +++++++++++ test/src/structures/TypeTagPatternFlags.ts | 38 +++++++++++ 5 files changed, 169 insertions(+), 25 deletions(-) create mode 100644 test/src/structures/CommentTagPatternFlags.ts create mode 100644 test/src/structures/TypeTagPatternFlags.ts diff --git a/src/factories/MetadataCommentTagFactory.ts b/src/factories/MetadataCommentTagFactory.ts index ba4b6f6032..4ed0126641 100644 --- a/src/factories/MetadataCommentTagFactory.ts +++ b/src/factories/MetadataCommentTagFactory.ts @@ -524,21 +524,56 @@ const PARSER: Record< ], }; }, - pattern: ({ value }) => ({ - string: [ - { - name: `Pattern<${JSON.stringify(value)}>`, - target: "string", - kind: "pattern", - value: value, - validate: `RegExp(${JSON.stringify(value)}).test($input)`, - exclusive: ["format"], - schema: { - pattern: value, + pattern: ({ value }) => { + // Support regex literal syntax: /pattern/flags + const match = value.match(/^\/(.*)\/([dgimsuy]*)$/); + if (match !== null) { + const [, pattern, flags] = match; + return { + string: [ + { + name: + flags === "" + ? `Pattern<${JSON.stringify(pattern)}>` + : `Pattern<${JSON.stringify(pattern)}, ${JSON.stringify(flags)}>`, + target: "string", + kind: "pattern", + value: pattern, + validate: + flags === "" + ? `RegExp(${JSON.stringify(pattern)}).test($input)` + : `RegExp(${JSON.stringify(pattern)}, ${JSON.stringify(flags)}).test($input)`, + exclusive: ["format"], + schema: + flags === "" + ? { + pattern: pattern, + } + : { + pattern: pattern, + "x-pattern-flags": flags, + }, + }, + ], + }; + } + // Legacy format: just the pattern (no flags) + return { + string: [ + { + name: `Pattern<${JSON.stringify(value)}>`, + target: "string", + kind: "pattern", + value: value, + validate: `RegExp(${JSON.stringify(value)}).test($input)`, + exclusive: ["format"], + schema: { + pattern: value, + }, }, - }, - ], - }), + ], + }; + }, length: (props) => ({ string: [ { diff --git a/src/programmers/RandomProgrammer.ts b/src/programmers/RandomProgrammer.ts index 4803192ace..1de036c284 100644 --- a/src/programmers/RandomProgrammer.ts +++ b/src/programmers/RandomProgrammer.ts @@ -504,7 +504,12 @@ export namespace RandomProgrammer { .join("")}`, arguments: [], }; - } else if (string.pattern !== undefined) + } else if (string.pattern !== undefined) { + const flags: string | undefined = ( + schema as OpenApi.IJsonSchema.IString & { + "x-pattern-flags"?: string; + } + )["x-pattern-flags"]; return { method: "pattern", internal: "randomPattern", @@ -512,14 +517,22 @@ export namespace RandomProgrammer { ts.factory.createNewExpression( ts.factory.createIdentifier("RegExp"), undefined, - [ - ts.factory.createStringLiteral( - (schema as OpenApi.IJsonSchema.IString).pattern!, - ), - ], + flags !== undefined + ? [ + ts.factory.createStringLiteral( + (schema as OpenApi.IJsonSchema.IString).pattern!, + ), + ts.factory.createStringLiteral(flags), + ] + : [ + ts.factory.createStringLiteral( + (schema as OpenApi.IJsonSchema.IString).pattern!, + ), + ], ), ], }; + } } else if (props.atomic.type === "number") { const number: | OpenApi.IJsonSchema.INumber diff --git a/src/tags/Pattern.ts b/src/tags/Pattern.ts index 56f4bcc02b..4196240757 100644 --- a/src/tags/Pattern.ts +++ b/src/tags/Pattern.ts @@ -13,20 +13,42 @@ import { TagBase } from "./TagBase"; * type HexColor = string & Pattern<"^#[0-9A-Fa-f]{6}$">; // #FF5733 * ``` * + * You can also specify regex flags as a second parameter: + * + * ```ts + * // Case-insensitive matching + * type CaseInsensitive = string & Pattern<"^hello$", "i">; + * + * // Unicode property escapes (requires 'u' flag) + * type Identifier = string & Pattern<"^[\\p{ID_Start}_$][\\p{ID_Continue}_$]*$", "u">; + * ``` + * + * Supported flags: `d`, `g`, `i`, `m`, `s`, `u`, `v`, `y` (can be combined) + * * Note: This tag is mutually exclusive with the Format tag. You cannot use both * Pattern and Format on the same type. * * @author Jeongho Nam - https://github.com/samchon */ -export type Pattern = TagBase<{ +export type Pattern< + Value extends string, + Flags extends string = "", +> = TagBase<{ target: "string"; kind: "pattern"; value: Value; - validate: `RegExp("${Serialize}").test($input)`; + validate: Flags extends "" + ? `RegExp("${Serialize}").test($input)` + : `RegExp("${Serialize}", "${Flags}").test($input)`; exclusive: ["format", "pattern"]; - schema: { - pattern: Value; - }; + schema: Flags extends "" + ? { + pattern: Value; + } + : { + pattern: Value; + "x-pattern-flags": Flags; + }; }>; /// reference: https://github.com/type-challenges/type-challenges/issues/22394#issuecomment-1397158205 diff --git a/test/src/structures/CommentTagPatternFlags.ts b/test/src/structures/CommentTagPatternFlags.ts new file mode 100644 index 0000000000..587373a335 --- /dev/null +++ b/test/src/structures/CommentTagPatternFlags.ts @@ -0,0 +1,36 @@ +import { Spoiler } from "../helpers/Spoiler"; + +export interface CommentTagPatternFlags { + /** @pattern /^hello$/i */ + caseInsensitive: string; + + /** @pattern /^hello$/m */ + multiline: string; + + /** @pattern /^[a-z]+$/im */ + multipleFlags: string; +} +export namespace CommentTagPatternFlags { + export function generate(): CommentTagPatternFlags { + return { + caseInsensitive: "HELLO", + multiline: "hello", + multipleFlags: "hello", + }; + } + + export const SPOILERS: Spoiler[] = [ + (input) => { + input.caseInsensitive = "world"; + return ["$input.caseInsensitive"]; + }, + (input) => { + input.multiline = "world"; + return ["$input.multiline"]; + }, + (input) => { + input.multipleFlags = "123"; + return ["$input.multipleFlags"]; + }, + ]; +} diff --git a/test/src/structures/TypeTagPatternFlags.ts b/test/src/structures/TypeTagPatternFlags.ts new file mode 100644 index 0000000000..7e74a99892 --- /dev/null +++ b/test/src/structures/TypeTagPatternFlags.ts @@ -0,0 +1,38 @@ +import typia from "typia"; + +import { Spoiler } from "../helpers/Spoiler"; + +export interface TypeTagPatternFlags { + // Case-insensitive matching + caseInsensitive: string & typia.tags.Pattern<"^hello$", "i">; + + // Multiline flag + multiline: string & typia.tags.Pattern<"^hello$", "m">; + + // Multiple flags combined + multipleFlags: string & typia.tags.Pattern<"^[a-z]+$", "im">; +} +export namespace TypeTagPatternFlags { + export function generate(): TypeTagPatternFlags { + return { + caseInsensitive: "HELLO", + multiline: "hello", + multipleFlags: "hello", + }; + } + + export const SPOILERS: Spoiler[] = [ + (input) => { + input.caseInsensitive = "world"; + return ["$input.caseInsensitive"]; + }, + (input) => { + input.multiline = "world"; + return ["$input.multiline"]; + }, + (input) => { + input.multipleFlags = "123"; + return ["$input.multipleFlags"]; + }, + ]; +}