Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
35 changes: 35 additions & 0 deletions ark/type/__tests__/regex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
})
9 changes: 7 additions & 2 deletions ark/type/parser/shift/operand/enclosed.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { rootSchema } from "@ark/schema"
import {
Backslash,
isKeyOf,
throwParseError,
type ErrorMessage,
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -84,7 +89,7 @@ export type parseEnclosed<
Scanner.shiftUntilEscapable<
unscanned,
EnclosingTokens[enclosingStart],
""
enclosingStart extends EnclosingRegexToken ? Backslash : ""
> extends Scanner.shiftResult<infer scanned, infer nextUnscanned> ?
_parseEnclosed<s, enclosingStart, scanned, nextUnscanned>
: never
Expand Down
8 changes: 6 additions & 2 deletions ark/util/scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,17 @@ export class Scanner<lookahead extends string = string> {
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()
Expand Down
Loading