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()