From 510ca5d317867c4bb8f9f11820e8876ff0220850 Mon Sep 17 00:00:00 2001 From: Yarchik Date: Wed, 1 Jul 2026 23:44:47 +0100 Subject: [PATCH] fix(type): preserve escaped backslash in regex string literals A regex written as a string literal was parsed with the string-escape rule, which collapses `\\` into a single `\`. That is correct for a JS string but corrupts a regex source, where `\\` is a literal backslash and `\d`/`\b`/`\w` are metacharacters. So `type("/^\\\\d$/")` silently became the digit-class regex `/^\d$/`, accepting "5" and rejecting "\d", disagreeing with the equivalent RegExp-instance form. Add an optional `escapeEscape` argument to `shiftUntilEscapable` and pass a backslash for regex tokens in both the runtime and type-level parsers, so a doubled backslash is preserved in the pattern source. Quoted-string and date literals keep collapsing `\\` as before; escaped terminators `\/` and single-backslash classes `\d`/`\w`/`\t` are unaffected. --- ark/type/__tests__/regex.test.ts | 35 +++++++++++++++++++++++ ark/type/parser/shift/operand/enclosed.ts | 9 ++++-- ark/util/scanner.ts | 8 ++++-- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/ark/type/__tests__/regex.test.ts b/ark/type/__tests__/regex.test.ts index 3739201289..75e97085db 100644 --- a/ark/type/__tests__/regex.test.ts +++ b/ark/type/__tests__/regex.test.ts @@ -106,4 +106,39 @@ contextualize(() => { }> >(T) }) + + describe("escaped backslash", () => { + // note: in a TS string, "\\\\d" is two literal backslashes followed by + // "d", i.e. the four characters `\`, `\`, `d` reach the parser, which is + // the regex source for a literal backslash + "d" (not the digit class) + it("string literal matches RegExp instance", () => { + const FromString = type("/^\\\\d$/") + const FromInstance = type(/^\\d$/) + attest(FromString.json).equals(FromInstance.json) + }) + + it("preserves literal backslash", () => { + const T = type("/^\\\\d$/") + attest(T.allows("\\d")).equals(true) + attest(T.allows("5")).equals(false) + }) + + it("lone escaped backslash", () => { + const T = type("/^\\\\$/") + attest(T.allows("\\")).equals(true) + }) + + it("single-backslash class still works", () => { + // "\\d" is a single backslash + "d", i.e. the digit-class metachar + const T = type("/^\\d+$/") + attest(T.allows("123")).equals(true) + attest(T.allows("abc")).equals(false) + }) + + it("escaped terminator preserved", () => { + const T = type("/^a\\/b$/") + attest(T.json).snap({ domain: "string", pattern: ["^a/b$"] }) + attest(T.allows("a/b")).equals(true) + }) + }) }) diff --git a/ark/type/parser/shift/operand/enclosed.ts b/ark/type/parser/shift/operand/enclosed.ts index e673c746e4..a3c2a34e2b 100644 --- a/ark/type/parser/shift/operand/enclosed.ts +++ b/ark/type/parser/shift/operand/enclosed.ts @@ -1,5 +1,6 @@ import { rootSchema } from "@ark/schema" import { + Backslash, isKeyOf, throwParseError, type ErrorMessage, @@ -36,7 +37,11 @@ export const parseEnclosed = ( enclosing: EnclosingStartToken ): void => { const enclosed = s.scanner.shiftUntilEscapable( - untilLookaheadIsClosing[enclosingTokens[enclosing]] + untilLookaheadIsClosing[enclosingTokens[enclosing]], + // within a regex literal, `\\` is a literal backslash and must be + // preserved verbatim in the pattern source; collapsing it to a single + // backslash would corrupt the regex (e.g. `\\d` -> `\d` digit class) + enclosing in enclosingRegexTokens ? Backslash : "" ) if (s.scanner.lookahead === "") return s.error(writeUnterminatedEnclosedMessage(enclosed, enclosing)) @@ -84,7 +89,7 @@ export type parseEnclosed< Scanner.shiftUntilEscapable< unscanned, EnclosingTokens[enclosingStart], - "" + enclosingStart extends EnclosingRegexToken ? Backslash : "" > extends Scanner.shiftResult ? _parseEnclosed : never diff --git a/ark/util/scanner.ts b/ark/util/scanner.ts index 2c62061834..01cbcc6d35 100644 --- a/ark/util/scanner.ts +++ b/ark/util/scanner.ts @@ -38,13 +38,17 @@ export class Scanner { return shifted } - shiftUntilEscapable(condition: Scanner.UntilCondition): string { + shiftUntilEscapable( + condition: Scanner.UntilCondition, + escapeEscape: typeof Backslash | "" = "" + ): string { let shifted = "" while (this.lookahead) { if (this.lookahead === Backslash) { this.shift() if (condition(this, shifted)) shifted += this.shift() - else if (this.lookahead === Backslash) shifted += this.shift() + else if (this.lookahead === Backslash) + shifted += `${escapeEscape}${this.shift()}` else shifted += `${Backslash}${this.shift()}` } else if (condition(this, shifted)) break else shifted += this.shift()