Skip to content

Commit 1327699

Browse files
committed
Support nesting non-capturing regexp groups
1 parent 153365c commit 1327699

File tree

3 files changed

+163
-66
lines changed

3 files changed

+163
-66
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
"size-limit": [
3535
{
3636
"path": "dist/index.js",
37-
"limit": "1.5 kB"
37+
"limit": "1.6 kB"
3838
}
3939
],
4040
"jest": {

src/index.spec.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2551,6 +2551,33 @@ const TESTS: Test[] = [
25512551
[{ test: "abc" }, "/abc"],
25522552
[{ test: "ABC" }, "/ABC"]
25532553
]
2554+
],
2555+
2556+
/**
2557+
* Nested parenthesis.
2558+
*/
2559+
[
2560+
"/:foo(\\d+(?:\\.\\d+)?)",
2561+
{},
2562+
[
2563+
{
2564+
name: "foo",
2565+
prefix: "/",
2566+
delimiter: "/",
2567+
optional: false,
2568+
repeat: false,
2569+
pattern: "\\d+(?:\\.\\d+)?"
2570+
}
2571+
],
2572+
[
2573+
["/123", ["/123", "123"]],
2574+
["/123.123", ["/123.123", "123.123"]]
2575+
],
2576+
[
2577+
[{ foo: 123 }, "/123"],
2578+
[{ foo: 123.123 }, "/123.123"],
2579+
[{ foo: "123" }, "/123"]
2580+
]
25542581
]
25552582
];
25562583

@@ -2591,6 +2618,22 @@ describe("path-to-regexp", function() {
25912618
expect(keys).toEqual([TEST_PARAM]);
25922619
expect(exec(re, "/user/123/show")).toEqual(["/user/123", "123"]);
25932620
});
2621+
2622+
it("should throw on non-capturing pattern group", function() {
2623+
expect(function() {
2624+
pathToRegexp.pathToRegexp("/:foo(?:\\d+(\\.\\d+)?)");
2625+
}).toThrow(new TypeError("Path pattern must be a capturing group"));
2626+
});
2627+
2628+
it("should throw on nested capturing regexp groups", function() {
2629+
expect(function() {
2630+
pathToRegexp.pathToRegexp("/:foo(\\d+(\\.\\d+)?)");
2631+
}).toThrow(
2632+
new TypeError(
2633+
"Capturing groups are not allowed in pattern, use a non-capturing group: (\\d+(?:\\.\\d+)?)"
2634+
)
2635+
);
2636+
});
25942637
});
25952638

25962639
describe("tokens", function() {

src/index.ts

Lines changed: 119 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,6 @@
33
*/
44
const DEFAULT_DELIMITER = "/";
55

6-
/**
7-
* The main path matching regexp utility.
8-
*/
9-
const PATH_REGEXP = new RegExp(
10-
[
11-
// Match escaped characters that would otherwise appear in future matches.
12-
// This allows the user to escape special characters that won't transform.
13-
"(\\\\.)",
14-
// Match Express-style parameters and un-named parameters with a prefix
15-
// and optional suffixes. Matches appear as:
16-
//
17-
// ":test(\\d+)?" => ["test", "\d+", undefined, "?"]
18-
// "(\\d+)" => [undefined, undefined, "\d+", undefined]
19-
"(?:\\:(\\w+)(?:\\(((?:\\\\.|[^\\\\()])+)\\))?|\\(((?:\\\\.|[^\\\\()])+)\\))([+*?])?"
20-
].join("|"),
21-
"g"
22-
);
23-
246
export interface ParseOptions {
257
/**
268
* Set the default delimiter for repeat parameters. (default: `'/'`)
@@ -39,70 +21,149 @@ export function parse(str: string, options: ParseOptions = {}): Token[] {
3921
const tokens = [];
4022
const defaultDelimiter = options.delimiter ?? DEFAULT_DELIMITER;
4123
const whitelist = options.whitelist ?? undefined;
24+
let i = 0;
4225
let key = 0;
43-
let index = 0;
4426
let path = "";
45-
let isPathEscaped = false;
46-
let res: RegExpExecArray | null;
27+
let isEscaped = false;
4728

4829
// tslint:disable-next-line
49-
while ((res = PATH_REGEXP.exec(str)) !== null) {
50-
const [m, escaped, name, capture, group, modifier] = res;
51-
let prev = "";
30+
while (i < str.length) {
31+
let prefix = "";
32+
let name = "";
33+
let pattern = "";
34+
35+
// Ignore escaped sequences.
36+
if (str[i] === "\\") {
37+
i++;
38+
path += str[i++];
39+
isEscaped = true;
40+
continue;
41+
}
42+
43+
if (str[i] === ":") {
44+
while (++i < str.length) {
45+
const code = str.charCodeAt(i);
46+
47+
if (
48+
// `0-9`
49+
(code >= 48 && code <= 57) ||
50+
// `A-Z`
51+
(code >= 65 && code <= 90) ||
52+
// `a-z`
53+
(code >= 97 && code <= 122) ||
54+
// `_`
55+
code === 95
56+
) {
57+
name += str[i];
58+
continue;
59+
}
60+
61+
break;
62+
}
63+
64+
// False positive on param name.
65+
if (!name) i--;
66+
}
67+
68+
if (str[i] === "(") {
69+
const prev = i;
70+
let balanced = 1;
71+
let invalidGroup = false;
72+
73+
if (str[i + 1] === "?") {
74+
throw new TypeError("Path pattern must be a capturing group");
75+
}
76+
77+
while (++i < str.length) {
78+
if (str[i] === "\\") {
79+
pattern += str.substr(i, 2);
80+
i++;
81+
continue;
82+
}
83+
84+
if (str[i] === ")") {
85+
balanced--;
86+
87+
if (balanced === 0) {
88+
i++;
89+
break;
90+
}
91+
}
92+
93+
pattern += str[i];
94+
95+
if (str[i] === "(") {
96+
balanced++;
97+
98+
// Better errors on nested capturing groups.
99+
if (str[i + 1] !== "?") {
100+
pattern += "?:";
101+
invalidGroup = true;
102+
}
103+
}
104+
}
105+
106+
if (invalidGroup) {
107+
throw new TypeError(
108+
`Capturing groups are not allowed in pattern, use a non-capturing group: (${pattern})`
109+
);
110+
}
52111

53-
path += str.slice(index, res.index);
54-
index = res.index + m.length;
112+
// False positive.
113+
if (balanced > 0) {
114+
i = prev;
115+
pattern = "";
116+
}
117+
}
55118

56-
// Ignore already escaped sequences.
57-
if (escaped) {
58-
path += escaped[1];
59-
isPathEscaped = true;
119+
// Add regular characters to the path string.
120+
if (name === "" && pattern === "") {
121+
path += str[i++];
122+
isEscaped = false;
60123
continue;
61124
}
62125

63-
if (!isPathEscaped && path.length) {
64-
const k = path.length - 1;
65-
const c = path[k];
66-
const matches = whitelist ? whitelist.indexOf(c) > -1 : true;
126+
// Extract the final character from `path` for the prefix.
127+
if (path.length && !isEscaped) {
128+
const char = path[path.length - 1];
129+
const matches = whitelist ? whitelist.indexOf(char) > -1 : true;
67130

68131
if (matches) {
69-
prev = c;
70-
path = path.slice(0, k);
132+
prefix = char;
133+
path = path.slice(0, -1);
71134
}
72135
}
73136

74-
// Push the current path onto the tokens.
75-
if (path) {
137+
// Push the current path onto the list of tokens.
138+
if (path.length) {
76139
tokens.push(path);
77140
path = "";
78-
isPathEscaped = false;
79141
}
80142

81-
const repeat = modifier === "+" || modifier === "*";
82-
const optional = modifier === "?" || modifier === "*";
83-
const pattern = capture || group;
84-
const delimiter = prev || defaultDelimiter;
143+
const repeat = str[i] === "+" || str[i] === "*";
144+
const optional = str[i] === "?" || str[i] === "*";
145+
const delimiter = prefix || defaultDelimiter;
146+
147+
// Increment `i` past modifier token.
148+
if (repeat || optional) i++;
85149

86150
tokens.push({
87151
name: name || key++,
88-
prefix: prev,
89-
delimiter: delimiter,
90-
optional: optional,
91-
repeat: repeat,
92-
pattern: pattern
93-
? escapeGroup(pattern)
94-
: `[^${escapeString(
95-
delimiter === defaultDelimiter
96-
? delimiter
97-
: delimiter + defaultDelimiter
98-
)}]+?`
152+
prefix,
153+
delimiter,
154+
optional,
155+
repeat,
156+
pattern:
157+
pattern ||
158+
`[^${escapeString(
159+
delimiter === defaultDelimiter
160+
? delimiter
161+
: delimiter + defaultDelimiter
162+
)}]+?`
99163
});
100164
}
101165

102-
// Push any remaining characters.
103-
if (path || index < str.length) {
104-
tokens.push(path + str.substr(index));
105-
}
166+
if (path.length) tokens.push(path);
106167

107168
return tokens;
108169
}
@@ -298,13 +359,6 @@ function escapeString(str: string) {
298359
return str.replace(/([.+*?=^!:${}()[\]|/\\])/g, "\\$1");
299360
}
300361

301-
/**
302-
* Escape the capturing group by escaping special characters and meaning.
303-
*/
304-
function escapeGroup(group: string) {
305-
return group.replace(/([=!:$/()])/g, "\\$1");
306-
}
307-
308362
/**
309363
* Get the flags for a regexp from the options.
310364
*/

0 commit comments

Comments
 (0)