Skip to content

Commit a7e29ab

Browse files
committed
feat: support parameter patterns
1 parent ed36cc2 commit a7e29ab

File tree

5 files changed

+121
-11
lines changed

5 files changed

+121
-11
lines changed

Readme.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,10 @@ Parameter names must be provided after `:` or `*`, and they must be a valid Java
192192

193193
Parameter names can be wrapped in double quote characters, and this error means you forgot to close the quote character.
194194

195+
### Unterminated parameter pattern
196+
197+
Parameter patterns must be wrapped in parentheses, and this error means you forgot to close the parentheses.
198+
195199
### Express <= 4.x
196200

197201
Path-To-RegExp breaks compatibility with Express <= `4.x` in the following ways:

src/cases.spec.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,20 @@ export const PARSER_TESTS: ParserTestSet[] = [
100100
{ type: "text", value: "stuff" },
101101
]),
102102
},
103+
{
104+
path: "/:locale(de|en)",
105+
expected: new TokenData([
106+
{ type: "text", value: "/" },
107+
{ type: "param", name: "locale", pattern: "de|en" },
108+
]),
109+
},
110+
{
111+
path: "/:foo(a|b|c)",
112+
expected: new TokenData([
113+
{ type: "text", value: "/" },
114+
{ type: "param", name: "foo", pattern: "a|b|c" },
115+
]),
116+
},
103117
];
104118

105119
export const STRINGIFY_TESTS: StringifyTestSet[] = [
@@ -270,6 +284,16 @@ export const COMPILE_TESTS: CompileTestSet[] = [
270284
{ input: { test: "123/xyz" }, expected: "/123/xyz" },
271285
],
272286
},
287+
{
288+
path: "/:locale(de|en)",
289+
tests: [
290+
{ input: undefined, expected: null },
291+
{ input: {}, expected: null },
292+
{ input: { locale: "de" }, expected: "/de" },
293+
{ input: { locale: "en" }, expected: "/en" },
294+
{ input: { locale: "fr" }, expected: "/fr" },
295+
],
296+
},
273297
];
274298

275299
/**
@@ -376,6 +400,28 @@ export const MATCH_TESTS: MatchTestSet[] = [
376400
],
377401
},
378402

403+
/**
404+
* Parameter patterns.
405+
*/
406+
{
407+
path: "/:locale(de|en)",
408+
tests: [
409+
{ input: "/de", expected: { path: "/de", params: { locale: "de" } } },
410+
{ input: "/en", expected: { path: "/en", params: { locale: "en" } } },
411+
{ input: "/fr", expected: false },
412+
{ input: "/", expected: false },
413+
],
414+
},
415+
{
416+
path: "/:foo(\\d)",
417+
tests: [
418+
{ input: "/1", expected: { path: "/1", params: { foo: "1" } } },
419+
{ input: "/123", expected: false },
420+
{ input: "/", expected: false },
421+
{ input: "/foo", expected: false },
422+
],
423+
},
424+
379425
/**
380426
* Case-sensitive paths.
381427
*/

src/index.bench.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const PATHS: string[] = [
1212

1313
const STATIC_PATH_MATCH = match("/user");
1414
const SIMPLE_PATH_MATCH = match("/user/:id");
15+
const SIMPLE_PATH_MATCH_WITH_PATTERN = match("/user/:id(\\d+)");
1516
const MULTI_SEGMENT_MATCH = match("/:x/:y");
1617
const MULTI_PATTERN_MATCH = match("/:x-:y");
1718
const TRICKY_PATTERN_MATCH = match("/:foo|:bar|");
@@ -25,6 +26,10 @@ bench("simple path", () => {
2526
for (const path of PATHS) SIMPLE_PATH_MATCH(path);
2627
});
2728

29+
bench("simple path with parameter pattern", () => {
30+
for (const path of PATHS) SIMPLE_PATH_MATCH_WITH_PATTERN(path);
31+
});
32+
2833
bench("multi segment", () => {
2934
for (const path of PATHS) MULTI_SEGMENT_MATCH(path);
3035
});

src/index.spec.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ describe("path-to-regexp", () => {
5050
),
5151
);
5252
});
53+
54+
it("should throw on unterminated parameter pattern", () => {
55+
expect(() => parse("/:foo((bar")).toThrow(
56+
new TypeError(
57+
"Unterminated parameter pattern at 10: https://git.new/pathToRegexpError",
58+
),
59+
);
60+
});
5361
});
5462

5563
describe("compile errors", () => {

src/index.ts

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ type TokenType =
8181
interface LexToken {
8282
type: TokenType;
8383
index: number;
84-
value: string;
84+
value: string | { name: string; pattern?: string };
8585
}
8686

8787
const SIMPLE_TOKENS: Record<string, TokenType> = {
@@ -119,7 +119,10 @@ function* lexer(str: string): Generator<LexToken, LexToken> {
119119
const chars = [...str];
120120
let i = 0;
121121

122-
function name() {
122+
function name(options?: { pattern?: boolean }): {
123+
name: string;
124+
pattern?: string;
125+
} {
123126
let value = "";
124127

125128
if (ID_START.test(chars[++i])) {
@@ -153,7 +156,29 @@ function* lexer(str: string): Generator<LexToken, LexToken> {
153156
throw new TypeError(`Missing parameter name at ${i}: ${DEBUG_URL}`);
154157
}
155158

156-
return value;
159+
if (chars[i] === "(" && options?.pattern) {
160+
let depth = 1;
161+
let pattern = "";
162+
i++;
163+
while (i < chars.length && depth > 0) {
164+
if (chars[i] === "(") {
165+
depth++;
166+
} else if (chars[i] === ")") {
167+
depth--;
168+
}
169+
if (depth > 0) {
170+
pattern += chars[i++];
171+
}
172+
}
173+
if (depth !== 0) {
174+
throw new TypeError(
175+
`Unterminated parameter pattern at ${i}: ${DEBUG_URL}`,
176+
);
177+
}
178+
i++;
179+
return { name: value, pattern };
180+
}
181+
return { name: value };
157182
}
158183

159184
while (i < chars.length) {
@@ -165,10 +190,14 @@ function* lexer(str: string): Generator<LexToken, LexToken> {
165190
} else if (value === "\\") {
166191
yield { type: "ESCAPED", index: i++, value: chars[i++] };
167192
} else if (value === ":") {
168-
const value = name();
169-
yield { type: "PARAM", index: i, value };
193+
const value = name({ pattern: true });
194+
yield {
195+
type: "PARAM",
196+
index: i,
197+
value,
198+
};
170199
} else if (value === "*") {
171-
const value = name();
200+
const { name: value } = name();
172201
yield { type: "WILDCARD", index: i, value };
173202
} else {
174203
yield { type: "CHAR", index: i, value: chars[i++] };
@@ -191,15 +220,23 @@ class Iter {
191220
return this._peek;
192221
}
193222

194-
tryConsume(type: TokenType): string | undefined {
223+
tryConsume(type: Extract<TokenType, "PARAM">): {
224+
name: string;
225+
pattern?: string;
226+
};
227+
tryConsume(type: Exclude<TokenType, "PARAM">): string;
228+
tryConsume(
229+
type: TokenType,
230+
): string | { name: string; pattern?: string } | undefined {
195231
const token = this.peek();
196232
if (token.type !== type) return;
197233
this._peek = undefined; // Reset after consumed.
198234
return token.value;
199235
}
200236

201-
consume(type: TokenType): string {
202-
const value = this.tryConsume(type);
237+
consume(type: TokenType): string | { name: string; pattern?: string } {
238+
const value =
239+
type === "PARAM" ? this.tryConsume(type) : this.tryConsume(type);
203240
if (value !== undefined) return value;
204241
const { type: nextType, index } = this.peek();
205242
throw new TypeError(
@@ -231,6 +268,7 @@ export interface Text {
231268
export interface Parameter {
232269
type: "param";
233270
name: string;
271+
pattern?: string;
234272
}
235273

236274
/**
@@ -287,9 +325,11 @@ export function parse(str: string, options: ParseOptions = {}): TokenData {
287325

288326
const param = it.tryConsume("PARAM");
289327
if (param) {
328+
const { name, pattern } = param;
290329
tokens.push({
291330
type: "param",
292-
name: param,
331+
name,
332+
pattern,
293333
});
294334
continue;
295335
}
@@ -579,7 +619,14 @@ function toRegExp(tokens: Flattened[], delimiter: string, keys: Keys) {
579619
}
580620

581621
if (token.type === "param") {
582-
result += `(${negate(delimiter, isSafeSegmentParam ? "" : backtrack)}+)`;
622+
if (token.pattern) {
623+
result += `(${token.pattern})`;
624+
} else {
625+
result += `(${negate(
626+
delimiter,
627+
isSafeSegmentParam ? "" : backtrack,
628+
)}+)`;
629+
}
583630
} else {
584631
result += `([\\s\\S]+)`;
585632
}

0 commit comments

Comments
 (0)