diff --git a/Readme.md b/Readme.md index eaa60b2..263c0f7 100644 --- a/Readme.md +++ b/Readme.md @@ -24,29 +24,9 @@ const { match, compile, parse } = require("path-to-regexp"); // parse(path, options?) ``` -### Match - -The `match` function returns a function for transforming paths into parameters: - -- **path** A string. -- **options** _(optional)_ (See [parse](#parse) for more options) - - **sensitive** Regexp will be case sensitive. (default: `false`) - - **end** Validate the match reaches the end of the string. (default: `true`) - - **decode** Function for decoding strings to params, or `false` to disable all processing. (default: `decodeURIComponent`) - -```js -const fn = match("/foo/:bar"); -``` - -**Please note:** `path-to-regexp` is intended for ordered data (e.g. pathnames, hostnames). It can not handle arbitrarily ordered data (e.g. query strings, URL fragments, JSON, etc). - ### Parameters -Parameters match arbitrary strings in a path by matching up to the end of the segment, or up to any proceeding tokens. - -#### Named parameters - -Named parameters are defined by prefixing a colon to the parameter name (`:foo`). Parameter names can use any valid unicode identifier characters, similar to JavaScript. +Parameters match arbitrary strings in a path by matching up to the end of the segment, or up to any proceeding tokens. They are defined by prefixing a colon to the parameter name (`:foo`). Parameter names can use any valid JavaScript identifier, or be double quoted to use other characters (`:"param-name"`). ```js const fn = match("/:foo/:bar"); @@ -55,137 +35,54 @@ fn("/test/route"); //=> { path: '/test/route', params: { foo: 'test', bar: 'route' } } ``` -##### Custom matching parameters - -Parameters can have a custom regexp, which overrides the default match (`[^/]+`). For example, you can match digits or names in a path: - -```js -const exampleNumbers = match("/icon-:foo(\\d+).png"); - -exampleNumbers("/icon-123.png"); -//=> { path: '/icon-123.png', params: { foo: '123' } } - -exampleNumbers("/icon-abc.png"); -//=> false - -const exampleWord = pathToRegexp("/(user|u)"); - -exampleWord("/u"); -//=> { path: '/u', params: { '0': 'u' } } - -exampleWord("/users"); -//=> false -``` - -**Tip:** Backslashes need to be escaped with another backslash in JavaScript strings. - -#### Unnamed parameters - -It is possible to define a parameter without a name. The name will be numerically indexed: - -```js -const fn = match("/:foo/(.*)"); - -fn("/test/route"); -//=> { path: '/test/route', params: { '0': 'route', foo: 'test' } } -``` - -#### Custom prefix and suffix - -Parameters can be wrapped in `{}` to create custom prefixes or suffixes for your segment: - -```js -const fn = match("{/:attr1}?{-:attr2}?{-:attr3}?"); - -fn("/test"); -//=> { path: '/test', params: { attr1: 'test' } } - -fn("/test-test"); -//=> { path: '/test-test', params: { attr1: 'test', attr2: 'test' } } -``` - -#### Modifiers - -Modifiers are used after parameters with custom prefixes and suffixes (`{}`). - -##### Optional - -Parameters can be suffixed with a question mark (`?`) to make the parameter optional. - -```js -const fn = match("/:foo{/:bar}?"); - -fn("/test"); -//=> { path: '/test', params: { foo: 'test' } } - -fn("/test/route"); -//=> { path: '/test/route', params: { foo: 'test', bar: 'route' } } -``` - -##### Zero or more +### Wildcard -Parameters can be suffixed with an asterisk (`*`) to denote a zero or more parameter matches. +Wildcard parameters match one or more characters across multiple segments. They are defined the same way as regular parameters, but are prefixed with an asterisk (`*foo`). ```js -const fn = match("{/:foo}*"); - -fn("/foo"); -//=> { path: '/foo', params: { foo: [ 'foo' ] } } +const fn = match("/*splat"); fn("/bar/baz"); -//=> { path: '/bar/baz', params: { foo: [ 'bar', 'baz' ] } } +//=> { path: '/bar/baz', params: { splat: [ 'bar', 'baz' ] } } ``` -##### One or more +### Optional -Parameters can be suffixed with a plus sign (`+`) to denote a one or more parameter matches. +Braces can be used to define parts of the path that are optional. ```js -const fn = match("{/:foo}+"); +const fn = match("/users{/:id}/delete"); -fn("/"); -//=> false +fn("/users/delete"); +//=> { path: '/users/delete', params: {} } -fn("/bar/baz"); -//=> { path: '/bar/baz', params: { foo: [ 'bar', 'baz' ] } } +fn("/users/123/delete"); +//=> { path: '/users/123/delete', params: { id: '123' } } ``` -##### Custom separator - -By default, parameters set the separator as the `prefix + suffix` of the token. Using `;` you can modify this: - -```js -const fn = match("/name{/:parts;-}+"); +## Match -fn("/name"); -//=> false +The `match` function returns a function for matching strings against a path: -fn("/bar/1-2-3"); -//=> { path: '/name/1-2-3', params: { parts: [ '1', '2', '3' ] } } -``` - -#### Wildcard - -A wildcard is also supported. It is roughly equivalent to `(.*)`. +- **path** String or array of strings. +- **options** _(optional)_ (See [parse](#parse) for more options) + - **sensitive** Regexp will be case sensitive. (default: `false`) + - **end** Validate the match reaches the end of the string. (default: `true`) + - **trailing** Allows optional trailing delimiter to match. (default: `true`) + - **decode** Function for decoding strings to params, or `false` to disable all processing. (default: `decodeURIComponent`) ```js -const fn = match("/*"); - -fn("/"); -//=> { path: '/', params: {} } - -fn("/bar/baz"); -//=> { path: '/bar/baz', params: { '0': [ 'bar', 'baz' ] } } +const fn = match("/foo/:bar"); ``` -### Compile ("Reverse" Path-To-RegExp) +**Please note:** `path-to-regexp` is intended for ordered data (e.g. pathnames, hostnames). It can not handle arbitrarily ordered data (e.g. query strings, URL fragments, JSON, etc). + +## Compile ("Reverse" Path-To-RegExp) The `compile` function will return a function for transforming parameters into a valid path: - **path** A string. - **options** (See [parse](#parse) for more options) - - **sensitive** Regexp will be case sensitive. (default: `false`) - - **validate** When `false` the function can produce an invalid (unmatched) path. (default: `true`) - **encode** Function for encoding input strings for output into the path, or `false` to disable entirely. (default: `encodeURIComponent`) ```js @@ -194,46 +91,34 @@ const toPath = compile("/user/:id"); toPath({ id: "name" }); //=> "/user/name" toPath({ id: "café" }); //=> "/user/caf%C3%A9" -// When disabling `encode`, you need to make sure inputs are encoded correctly. No arrays are accepted. -const toPathRaw = compile("/user/:id", { encode: false }); - -toPathRaw({ id: "%3A%2F" }); //=> "/user/%3A%2F" -toPathRaw({ id: ":/" }); //=> Throws, "/user/:/" when `validate` is `false`. - -const toPathRepeated = compile("{/:segment}+"); +const toPathRepeated = compile("/*segment"); toPathRepeated({ segment: ["foo"] }); //=> "/foo" toPathRepeated({ segment: ["a", "b", "c"] }); //=> "/a/b/c" -const toPathRegexp = compile("/user/:id(\\d+)"); +// When disabling `encode`, you need to make sure inputs are encoded correctly. No arrays are accepted. +const toPathRaw = compile("/user/:id", { encode: false }); -toPathRegexp({ id: "123" }); //=> "/user/123" +toPathRaw({ id: "%3A%2F" }); //=> "/user/%3A%2F" ``` ## Developers - If you are rewriting paths with match and compile, consider using `encode: false` and `decode: false` to keep raw paths passed around. -- To ensure matches work on paths containing characters usually encoded, consider using [encodeurl](https://github.com/pillarjs/encodeurl) for `encodePath`. +- To ensure matches work on paths containing characters usually encoded, such as emoji, consider using [encodeurl](https://github.com/pillarjs/encodeurl) for `encodePath`. ### Parse -The `parse` function accepts a string and returns `TokenData`, the set of tokens and other metadata parsed from the input string. `TokenData` is can used with `$match` and `$compile`. +The `parse` function accepts a string and returns `TokenData`, the set of tokens and other metadata parsed from the input string. `TokenData` is can used with `match` and `compile`. - **path** A string. - **options** _(optional)_ - **delimiter** The default delimiter for segments, e.g. `[^/]` for `:named` parameters. (default: `'/'`) - - **encodePath** A function for encoding input strings. (default: `x => x`, recommended: [`encodeurl`](https://github.com/pillarjs/encodeurl) for unicode encoding) + - **encodePath** A function for encoding input strings. (default: `x => x`, recommended: [`encodeurl`](https://github.com/pillarjs/encodeurl)) ### Tokens -The `tokens` returned by `TokenData` is an array of strings or keys, represented as objects, with the following properties: - -- `name` The name of the token -- `prefix` _(optional)_ The prefix string for the segment (e.g. `"/"`) -- `suffix` _(optional)_ The suffix string for the segment (e.g. `""`) -- `pattern` _(optional)_ The pattern defined to match this token -- `modifier` _(optional)_ The modifier character used for the segment (e.g. `?`) -- `separator` _(optional)_ The string used to separate repeated parameters +`TokenData` is a sequence of tokens, currently of types `text`, `parameter`, `wildcard`, or `group`. ### Custom path @@ -242,9 +127,12 @@ In some applications, you may not be able to use the `path-to-regexp` syntax, bu ```js import { TokenData, match } from "path-to-regexp"; -const tokens = ["/", { name: "foo" }]; -const path = new TokenData(tokens, "/"); -const fn = $match(path); +const tokens = [ + { type: "text", value: "/" }, + { type: "parameter", name: "foo" }, +]; +const path = new TokenData(tokens); +const fn = match(path); fn("/test"); //=> { path: '/test', index: 0, params: { foo: 'test' } } ``` @@ -253,55 +141,34 @@ fn("/test"); //=> { path: '/test', index: 0, params: { foo: 'test' } } An effort has been made to ensure ambiguous paths from previous releases throw an error. This means you might be seeing an error when things worked before. -### Unexpected `?`, `*`, or `+` - -In previous major versions `/` and `.` were used as implicit prefixes of parameters. So `/:key?` was implicitly `{/:key}?`. For example: - -- `/:key?` → `{/:key}?` or `/:key*` → `{/:key}*` or `/:key+` → `{/:key}+` -- `.:key?` → `{.:key}?` or `.:key*` → `{.:key}*` or `.:key+` → `{.:key}+` -- `:key?` → `{:key}?` or `:key*` → `{:key}*` or `:key+` → `{:key}+` +### Unexpected `?` or `+` -### Unexpected `;` +In past releases, `?`, `*`, and `+` were used to denote optional or repeating parameters. As an alternative, try these: -Used as a [custom separator](#custom-separator) for repeated parameters. +- For optional (`?`), use an empty segment in a group such as `/:file{.:ext}`. +- For repeating (`+`), only wildcard matching is supported, such as `/*path`. +- For optional repeating (`*`), use a group and a wildcard parameter such as `/files{/*path}`. -### Unexpected `!`, `@`, or `,` +### Unexpected `(`, `)`, `[`, `]`, etc. -These characters have been reserved for future use. - -### Missing separator - -Repeated parameters must have a separator to be valid. For example, `{:foo}*` can't be used. Separators can be defined manually, such as `{:foo;/}*`, or they default to the suffix and prefix with the parameter, such as `{/:foo}*`. +Previous versions of Path-to-RegExp used these for RegExp features. This version no longer supports them so they've been reserved to avoid ambiguity. To use these characters literally, escape them with a backslash, e.g. `"\\("`. ### Missing parameter name -Parameter names, the part after `:`, must be a valid JavaScript identifier. For example, it cannot start with a number or dash. If you want a parameter name that uses these characters you can wrap the name in quotes, e.g. `:"my-name"`. +Parameter names, the part after `:` or `*`, must be a valid JavaScript identifier. For example, it cannot start with a number or contain a dash. If you want a parameter name that uses these characters you can wrap the name in quotes, e.g. `:"my-name"`. ### Unterminated quote Parameter names can be wrapped in double quote characters, and this error means you forgot to close the quote character. -### Pattern cannot start with "?" - -Parameters in `path-to-regexp` must be basic groups. However, you can use features that require the `?` nested within the pattern. For example, `:foo((?!login)[^/]+)` is valid, but `:foo(?!login)` is not. - -### Capturing groups are not allowed - -A parameter pattern can not contain nested capturing groups. - -### Unbalanced or missing pattern - -A parameter pattern must have the expected number of parentheses. An unbalanced amount, such as `((?!login)` implies something has been written that is invalid. Check you didn't forget any parentheses. - ### Express <= 4.x Path-To-RegExp breaks compatibility with Express <= `4.x` in the following ways: -- The only part of the string that is a regex is within `()`. - - In Express.js 4.x, everything was passed as-is after a simple replacement, so you could write `/[a-z]+` to match `/test`. -- The `?` optional character must be used after `{}`. +- Regexp characters can no longer be provided. +- The optional character `?` is no longer supported, use braces instead: `/:file{.:ext}`. - Some characters have new meaning or have been reserved (`{}?*+@!;`). -- The parameter name now supports all unicode identifier characters, previously it was only `[a-z0-9]`. +- The parameter name now supports all JavaScript identifier characters, previously it was only `[a-z0-9]`. ## License diff --git a/package.json b/package.json index e1c220a..a57212a 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "dist/" ], "scripts": { + "bench": "vitest bench", "build": "ts-scripts build", "format": "ts-scripts format", "lint": "ts-scripts lint", diff --git a/scripts/redos.ts b/scripts/redos.ts index f83e52d..841cd07 100644 --- a/scripts/redos.ts +++ b/scripts/redos.ts @@ -5,13 +5,7 @@ import { MATCH_TESTS } from "../src/cases.spec.js"; let safe = 0; let fail = 0; -const TESTS = new Set(MATCH_TESTS.map((test) => test.path)); -// const TESTS = [ -// ":path([^\\.]+).:ext", -// ":path.:ext(\\w+)", -// ":path{.:ext([^\\.]+)}", -// "/:path.:ext(\\\\w+)", -// ]; +const TESTS = MATCH_TESTS.map((x) => x.path); for (const path of TESTS) { const { re } = match(path) as any; diff --git a/src/cases.spec.ts b/src/cases.spec.ts index 508b946..ef06e1f 100644 --- a/src/cases.spec.ts +++ b/src/cases.spec.ts @@ -34,31 +34,56 @@ export interface MatchTestSet { export const PARSER_TESTS: ParserTestSet[] = [ { path: "/", - expected: ["/"], + expected: [{ type: "text", value: "/" }], }, { path: "/:test", - expected: ["/", { name: "test" }], + expected: [ + { type: "text", value: "/" }, + { type: "param", name: "test" }, + ], }, { path: '/:"0"', - expected: ["/", { name: "0" }], + expected: [ + { type: "text", value: "/" }, + { type: "param", name: "0" }, + ], }, { path: "/:_", - expected: ["/", { name: "_" }], + expected: [ + { type: "text", value: "/" }, + { type: "param", name: "_" }, + ], }, { path: "/:café", - expected: ["/", { name: "café" }], + expected: [ + { type: "text", value: "/" }, + { type: "param", name: "café" }, + ], }, { path: '/:"123"', - expected: ["/", { name: "123" }], + expected: [ + { type: "text", value: "/" }, + { type: "param", name: "123" }, + ], }, { path: '/:"1\\"\\2\\"3"', - expected: ["/", { name: '1"2"3' }], + expected: [ + { type: "text", value: "/" }, + { type: "param", name: '1"2"3' }, + ], + }, + { + path: "/*path", + expected: [ + { type: "text", value: "/" }, + { type: "wildcard", name: "path" }, + ], }, ]; @@ -106,7 +131,6 @@ export const COMPILE_TESTS: CompileTestSet[] = [ }, { path: "/:test", - options: { validate: false }, tests: [ { input: undefined, expected: null }, { input: {}, expected: null }, @@ -116,7 +140,7 @@ export const COMPILE_TESTS: CompileTestSet[] = [ }, { path: "/:test", - options: { validate: false, encode: false }, + options: { encode: false }, tests: [ { input: undefined, expected: null }, { input: {}, expected: null }, @@ -124,16 +148,6 @@ export const COMPILE_TESTS: CompileTestSet[] = [ { input: { test: "123/xyz" }, expected: "/123/xyz" }, ], }, - { - path: "/:test", - options: { encode: encodeURIComponent }, - tests: [ - { input: undefined, expected: null }, - { input: {}, expected: null }, - { input: { test: "123" }, expected: "/123" }, - { input: { test: "123/xyz" }, expected: "/123%2Fxyz" }, - ], - }, { path: "/:test", options: { encode: () => "static" }, @@ -145,56 +159,32 @@ export const COMPILE_TESTS: CompileTestSet[] = [ ], }, { - path: "{/:test}?", + path: "{/:test}", options: { encode: false }, tests: [ { input: undefined, expected: "" }, { input: {}, expected: "" }, { input: { test: undefined }, expected: "" }, { input: { test: "123" }, expected: "/123" }, - { input: { test: "123/xyz" }, expected: null }, + { input: { test: "123/xyz" }, expected: "/123/xyz" }, ], }, { - path: "/:test(.*)", - options: { encode: false }, + path: "/*test", tests: [ { input: undefined, expected: null }, { input: {}, expected: null }, - { input: { test: "" }, expected: "/" }, - { input: { test: "123" }, expected: "/123" }, - { input: { test: "123/xyz" }, expected: "/123/xyz" }, - ], - }, - { - path: "{/:test}*", - tests: [ - { input: undefined, expected: "" }, - { input: {}, expected: "" }, - { input: { test: [] }, expected: "" }, - { input: { test: [""] }, expected: null }, + { input: { test: [] }, expected: null }, { input: { test: ["123"] }, expected: "/123" }, - { input: { test: "123/xyz" }, expected: null }, { input: { test: ["123", "xyz"] }, expected: "/123/xyz" }, ], }, { - path: "{/:test}*", + path: "/*test", options: { encode: false }, tests: [ - { input: undefined, expected: "" }, - { input: {}, expected: "" }, - { input: { test: "" }, expected: null }, { input: { test: "123" }, expected: "/123" }, { input: { test: "123/xyz" }, expected: "/123/xyz" }, - { input: { test: ["123", "xyz"] }, expected: null }, - ], - }, - { - path: "/{<:foo>}+", - tests: [ - { input: undefined, expected: null }, - { input: { foo: ["x", "y", "z"] }, expected: "/" }, ], }, ]; @@ -227,7 +217,7 @@ export const MATCH_TESTS: MatchTestSet[] = [ { input: "/test/route", expected: false }, { input: "/test/", - expected: false, + expected: { path: "/test/", params: {} }, }, ], }, @@ -242,7 +232,7 @@ export const MATCH_TESTS: MatchTestSet[] = [ { input: "/test", expected: false }, { input: "/test//", - expected: false, + expected: { path: "/test//", params: {} }, }, ], }, @@ -255,7 +245,7 @@ export const MATCH_TESTS: MatchTestSet[] = [ }, { input: "/route/", - expected: false, + expected: { path: "/route/", params: { test: "route" } }, }, { input: "/route.json", @@ -266,7 +256,10 @@ export const MATCH_TESTS: MatchTestSet[] = [ }, { input: "/route.json/", - expected: false, + expected: { + path: "/route.json/", + params: { test: "route.json" }, + }, }, { input: "/route/test", @@ -341,7 +334,7 @@ export const MATCH_TESTS: MatchTestSet[] = [ }, { input: "/test/", - expected: { path: "/test", params: {} }, + expected: { path: "/test/", params: {} }, }, { input: "/test////", @@ -377,7 +370,7 @@ export const MATCH_TESTS: MatchTestSet[] = [ }, { input: "/test//", - expected: { path: "/test/", params: {} }, + expected: { path: "/test//", params: {} }, }, { input: "/test/route", @@ -401,7 +394,7 @@ export const MATCH_TESTS: MatchTestSet[] = [ }, { input: "/route/", - expected: { path: "/route", params: { test: "route" } }, + expected: { path: "/route/", params: { test: "route" } }, }, { input: "/route.json", @@ -413,7 +406,7 @@ export const MATCH_TESTS: MatchTestSet[] = [ { input: "/route.json/", expected: { - path: "/route.json", + path: "/route.json/", params: { test: "route.json" }, }, }, @@ -477,7 +470,7 @@ export const MATCH_TESTS: MatchTestSet[] = [ }, { input: "/", - expected: { path: "", params: {} }, + expected: { path: "/", params: {} }, }, { input: "route", @@ -498,7 +491,28 @@ export const MATCH_TESTS: MatchTestSet[] = [ * Optional. */ { - path: "{/:test}?", + path: "{/route}", + tests: [ + { + input: "", + expected: { path: "", params: {} }, + }, + { + input: "/", + expected: { path: "/", params: {} }, + }, + { + input: "/foo", + expected: false, + }, + { + input: "/route", + expected: { path: "/route", params: {} }, + }, + ], + }, + { + path: "{/:test}", tests: [ { input: "/route", @@ -510,12 +524,12 @@ export const MATCH_TESTS: MatchTestSet[] = [ }, { input: "/", - expected: false, + expected: { path: "/", params: {} }, }, ], }, { - path: "{/:test}?/bar", + path: "{/:test}/bar", tests: [ { input: "/bar", @@ -527,12 +541,12 @@ export const MATCH_TESTS: MatchTestSet[] = [ }, { input: "/foo/bar/", - expected: false, + expected: { path: "/foo/bar/", params: { test: "foo" } }, }, ], }, { - path: "{/:test}?-bar", + path: "{/:test}-bar", tests: [ { input: "-bar", @@ -544,12 +558,12 @@ export const MATCH_TESTS: MatchTestSet[] = [ }, { input: "/foo-bar/", - expected: false, + expected: { path: "/foo-bar/", params: { test: "foo" } }, }, ], }, { - path: "/{:test}?-bar", + path: "/{:test}-bar", tests: [ { input: "/-bar", @@ -561,810 +575,266 @@ export const MATCH_TESTS: MatchTestSet[] = [ }, { input: "/foo-bar/", - expected: false, + expected: { path: "/foo-bar/", params: { test: "foo" } }, }, ], }, /** - * Zero or more times. + * No prefix characters. */ { - path: "{/:test}*", + path: "test", tests: [ { - input: "/", - expected: false, + input: "test", + expected: { path: "test", params: {} }, }, { - input: "//", + input: "/test", expected: false, }, - { - input: "/route", - expected: { path: "/route", params: { test: ["route"] } }, - }, - { - input: "/some/basic/route", - expected: { - path: "/some/basic/route", - params: { test: ["some", "basic", "route"] }, - }, - }, ], }, { - path: "{/:test}*-bar", + path: ":test", tests: [ { - input: "-bar", - expected: { path: "-bar", params: {} }, + input: "route", + expected: { path: "route", params: { test: "route" } }, }, { - input: "/-bar", + input: "/route", expected: false, }, { - input: "/foo-bar", - expected: { path: "/foo-bar", params: { test: ["foo"] } }, + input: "route/", + expected: { path: "route/", params: { test: "route" } }, + }, + ], + }, + { + path: "{:test}", + tests: [ + { + input: "test", + expected: { path: "test", params: { test: "test" } }, }, { - input: "/foo/baz-bar", - expected: { - path: "/foo/baz-bar", - params: { test: ["foo", "baz"] }, - }, + input: "", + expected: { path: "", params: {} }, }, ], }, /** - * One or more times. + * Formats. */ { - path: "{/:test}+", + path: "/test.json", tests: [ { - input: "/", - expected: false, + input: "/test.json", + expected: { path: "/test.json", params: {} }, }, { - input: "//", + input: "/test", expected: false, }, - { - input: "/route", - expected: { path: "/route", params: { test: ["route"] } }, - }, - { - input: "/some/basic/route", - expected: { - path: "/some/basic/route", - params: { test: ["some", "basic", "route"] }, - }, - }, ], }, { - path: "{/:test}+-bar", + path: "/:test.json", tests: [ { - input: "-bar", + input: "/.json", expected: false, }, { - input: "/-bar", - expected: false, + input: "/test.json", + expected: { path: "/test.json", params: { test: "test" } }, }, { - input: "/foo-bar", - expected: { path: "/foo-bar", params: { test: ["foo"] } }, + input: "/route.json", + expected: { path: "/route.json", params: { test: "route" } }, }, { - input: "/foo/baz-bar", - expected: { - path: "/foo/baz-bar", - params: { test: ["foo", "baz"] }, - }, + input: "/route.json.json", + expected: { path: "/route.json.json", params: { test: "route.json" } }, }, ], }, /** - * Custom parameters. + * Format and path params. */ { - path: String.raw`/:test(\d+)`, + path: "/:test.:format", tests: [ { - input: "/123", - expected: { path: "/123", params: { test: "123" } }, + input: "/route.html", + expected: { + path: "/route.html", + params: { test: "route", format: "html" }, + }, }, { - input: "/abc", + input: "/route", expected: false, }, { - input: "/123/abc", - expected: false, + input: "/route.html.json", + expected: { + path: "/route.html.json", + params: { test: "route.html", format: "json" }, + }, }, ], }, { - path: String.raw`/:test(\d+)-bar`, + path: "/:test{.:format}", tests: [ { - input: "-bar", - expected: false, + input: "/route", + expected: { path: "/route", params: { test: "route" } }, }, { - input: "/-bar", - expected: false, + input: "/route.json", + expected: { + path: "/route.json", + params: { test: "route", format: "json" }, + }, }, { - input: "/abc-bar", - expected: false, + input: "/route.json.html", + expected: { + path: "/route.json.html", + params: { test: "route.json", format: "html" }, + }, }, + ], + }, + { + path: "/:test.:format\\z", + tests: [ { - input: "/123-bar", - expected: { path: "/123-bar", params: { test: "123" } }, + input: "/route.htmlz", + expected: { + path: "/route.htmlz", + params: { test: "route", format: "html" }, + }, }, { - input: "/123/456-bar", + input: "/route.html", expected: false, }, ], }, + + /** + * Escaped characters. + */ { - path: "/:test(.*)", + path: "/\\(testing\\)", tests: [ { - input: "/", - expected: { path: "/", params: { test: "" } }, + input: "/testing", + expected: false, }, { - input: "/route", - expected: { path: "/route", params: { test: "route" } }, + input: "/(testing)", + expected: { path: "/(testing)", params: {} }, }, + ], + }, + { + path: "/.\\+\\*\\?\\{\\}=^\\!\\:$\\[\\]\\|", + tests: [ { - input: "/route/123", - expected: { - path: "/route/123", - params: { test: "route/123" }, - }, + input: "/.+*?{}=^!:$[]|", + expected: { path: "/.+*?{}=^!:$[]|", params: {} }, }, + ], + }, + + /** + * Random examples. + */ + { + path: "/:foo/:bar", + tests: [ { - input: "/;,:@&=/+$-_.!/~*()", + input: "/match/route", expected: { - path: "/;,:@&=/+$-_.!/~*()", - params: { test: ";,:@&=/+$-_.!/~*()" }, + path: "/match/route", + params: { foo: "match", bar: "route" }, }, }, ], }, { - path: "/:test([a-z]+)", + path: "/:foo\\(test\\)/bar", tests: [ { - input: "/abc", - expected: { path: "/abc", params: { test: "abc" } }, - }, - { - input: "/123", - expected: false, + input: "/foo(test)/bar", + expected: { path: "/foo(test)/bar", params: { foo: "foo" } }, }, { - input: "/abc/123", + input: "/foo/bar", expected: false, }, ], }, { - path: "/:test(this|that)", + path: "/:foo\\?", tests: [ { - input: "/this", - expected: { path: "/this", params: { test: "this" } }, - }, - { - input: "/that", - expected: { path: "/that", params: { test: "that" } }, + input: "/route?", + expected: { path: "/route?", params: { foo: "route" } }, }, { - input: "/foo", + input: "/route", expected: false, }, ], }, { - path: "{/:test(abc|xyz)}*", + path: "/{:pre}baz", tests: [ { - input: "/", - expected: false, + input: "/foobaz", + expected: { path: "/foobaz", params: { pre: "foo" } }, }, { - input: "/abc", - expected: { path: "/abc", params: { test: ["abc"] } }, + input: "/baz", + expected: { path: "/baz", params: { pre: undefined } }, }, + ], + }, + { + path: "/:foo\\(:bar\\)", + tests: [ { - input: "/abc/abc", + input: "/hello(world)", expected: { - path: "/abc/abc", - params: { test: ["abc", "abc"] }, + path: "/hello(world)", + params: { foo: "hello", bar: "world" }, }, }, { - input: "/xyz/xyz", - expected: { - path: "/xyz/xyz", - params: { test: ["xyz", "xyz"] }, - }, + input: "/hello()", + expected: false, }, + ], + }, + { + path: "/:foo\\({:bar}\\)", + tests: [ { - input: "/abc/xyz", + input: "/hello(world)", expected: { - path: "/abc/xyz", - params: { test: ["abc", "xyz"] }, - }, - }, - { - input: "/abc/xyz/abc/xyz", - expected: { - path: "/abc/xyz/abc/xyz", - params: { test: ["abc", "xyz", "abc", "xyz"] }, - }, - }, - { - input: "/xyzxyz", - expected: false, - }, - ], - }, - - /** - * No prefix characters. - */ - { - path: "test", - tests: [ - { - input: "test", - expected: { path: "test", params: {} }, - }, - { - input: "/test", - expected: false, - }, - ], - }, - { - path: ":test", - tests: [ - { - input: "route", - expected: { path: "route", params: { test: "route" } }, - }, - { - input: "/route", - expected: false, - }, - { - input: "route/", - expected: false, - }, - ], - }, - { - path: "{:test}?", - tests: [ - { - input: "test", - expected: { path: "test", params: { test: "test" } }, - }, - { - input: "", - expected: { path: "", params: {} }, - }, - ], - }, - { - path: "{:test/}+", - tests: [ - { - input: "route/", - expected: { path: "route/", params: { test: ["route"] } }, - }, - { - input: "/route", - expected: false, - }, - { - input: "", - expected: false, - }, - { - input: "foo/bar/", - expected: { - path: "foo/bar/", - params: { test: ["foo", "bar"] }, - }, - }, - ], - }, - - /** - * Formats. - */ - { - path: "/test.json", - tests: [ - { - input: "/test.json", - expected: { path: "/test.json", params: {} }, - }, - { - input: "/test", - expected: false, - }, - ], - }, - { - path: "/:test.json", - tests: [ - { - input: "/.json", - expected: false, - }, - { - input: "/test.json", - expected: { path: "/test.json", params: { test: "test" } }, - }, - { - input: "/route.json", - expected: { path: "/route.json", params: { test: "route" } }, - }, - { - input: "/route.json.json", - expected: false, - }, - ], - }, - { - path: "/:test([^/]+).json", - tests: [ - { - input: "/route.json.json", - expected: { - path: "/route.json.json", - params: { test: "route.json" }, - }, - }, - ], - }, - - /** - * Format params. - */ - { - path: "/test.:format(\\w+)", - tests: [ - { - input: "/test.html", - expected: { path: "/test.html", params: { format: "html" } }, - }, - { - input: "/test", - expected: false, - }, - ], - }, - { - path: "/test.:format(\\w+).:format(\\w+)", - tests: [ - { - input: "/test.html.json", - expected: { - path: "/test.html.json", - params: { format: "json" }, - }, - }, - { - input: "/test.html", - expected: false, - }, - ], - }, - { - path: "/test{.:format(\\w+)}?", - tests: [ - { - input: "/test", - expected: { path: "/test", params: { format: undefined } }, - }, - { - input: "/test.html", - expected: { path: "/test.html", params: { format: "html" } }, - }, - ], - }, - { - path: "/test{.:format(\\w+)}+", - tests: [ - { - input: "/test", - expected: false, - }, - { - input: "/test.html", - expected: { - path: "/test.html", - params: { format: ["html"] }, - }, - }, - { - input: "/test.html.json", - expected: { - path: "/test.html.json", - params: { format: ["html", "json"] }, - }, - }, - ], - }, - { - path: "/test{.:format}+", - tests: [ - { - input: "/test", - expected: false, - }, - { - input: "/test.html", - expected: { - path: "/test.html", - params: { format: ["html"] }, - }, - }, - { - input: "/test.hbs.html", - expected: { - path: "/test.hbs.html", - params: { format: ["hbs", "html"] }, - }, - }, - ], - }, - - /** - * Format and path params. - */ - { - path: "/:test.:format", - tests: [ - { - input: "/route.html", - expected: { - path: "/route.html", - params: { test: "route", format: "html" }, - }, - }, - { - input: "/route", - expected: false, - }, - { - input: "/route.html.json", - expected: { - path: "/route.html.json", - params: { test: "route", format: "html.json" }, - }, - }, - ], - }, - { - path: "/:test{.:format}?", - tests: [ - { - input: "/route", - expected: { path: "/route", params: { test: "route" } }, - }, - { - input: "/route.json", - expected: { - path: "/route.json", - params: { test: "route", format: "json" }, - }, - }, - { - input: "/route.json.html", - expected: { - path: "/route.json.html", - params: { test: "route", format: "json.html" }, - }, - }, - ], - }, - { - path: "/:test.:format\\z", - tests: [ - { - input: "/route.htmlz", - expected: { - path: "/route.htmlz", - params: { test: "route", format: "html" }, - }, - }, - { - input: "/route.html", - expected: false, - }, - ], - }, - - /** - * Unnamed params. - */ - { - path: "/(\\d+)", - tests: [ - { - input: "/123", - expected: { path: "/123", params: { "0": "123" } }, - }, - { - input: "/abc", - expected: false, - }, - { - input: "/123/abc", - expected: false, - }, - ], - }, - { - path: "{/(\\d+)}?", - tests: [ - { - input: "/", - expected: false, - }, - { - input: "/123", - expected: { path: "/123", params: { "0": "123" } }, - }, - ], - }, - { - path: "/route\\(\\\\(\\d+\\\\)\\)", - tests: [ - { - input: "/route(\\123\\)", - expected: { - path: "/route(\\123\\)", - params: { "0": "123\\" }, - }, - }, - { - input: "/route(\\123)", - expected: false, - }, - ], - }, - { - path: "{/route}?", - tests: [ - { - input: "", - expected: { path: "", params: {} }, - }, - { - input: "/", - expected: false, - }, - { - input: "/foo", - expected: false, - }, - { - input: "/route", - expected: { path: "/route", params: {} }, - }, - ], - }, - { - path: "{/(.*)}", - tests: [ - { - input: "/", - expected: { path: "/", params: { "0": "" } }, - }, - { - input: "/login", - expected: { path: "/login", params: { "0": "login" } }, - }, - ], - }, - - /** - * Escaped characters. - */ - { - path: "/\\(testing\\)", - tests: [ - { - input: "/testing", - expected: false, - }, - { - input: "/(testing)", - expected: { path: "/(testing)", params: {} }, - }, - ], - }, - { - path: "/.\\+\\*\\?\\{\\}=^\\!\\:$[]\\|", - tests: [ - { - input: "/.+*?{}=^!:$[]|", - expected: { path: "/.+*?{}=^!:$[]|", params: {} }, - }, - ], - }, - { - path: "/test/{:uid(u\\d+)}?{:cid(c\\d+)}?", - tests: [ - { - input: "/test/u123", - expected: { path: "/test/u123", params: { uid: "u123" } }, - }, - { - input: "/test/c123", - expected: { path: "/test/c123", params: { cid: "c123" } }, - }, - ], - }, - - /** - * Unnamed group prefix. - */ - { - path: "/{apple-}?icon-:res(\\d+).png", - tests: [ - { - input: "/icon-240.png", - expected: { path: "/icon-240.png", params: { res: "240" } }, - }, - { - input: "/apple-icon-240.png", - expected: { - path: "/apple-icon-240.png", - params: { res: "240" }, - }, - }, - ], - }, - - /** - * Random examples. - */ - { - path: "/:foo/:bar", - tests: [ - { - input: "/match/route", - expected: { - path: "/match/route", - params: { foo: "match", bar: "route" }, - }, - }, - ], - }, - { - path: "/:foo\\(test\\)/bar", - tests: [ - { - input: "/foo(test)/bar", - expected: { path: "/foo(test)/bar", params: { foo: "foo" } }, - }, - { - input: "/foo/bar", - expected: false, - }, - ], - }, - { - path: "/:remote([\\w\\-\\.]+)/:user([\\w-]+)", - tests: [ - { - input: "/endpoint/user", - expected: { - path: "/endpoint/user", - params: { remote: "endpoint", user: "user" }, - }, - }, - { - input: "/endpoint/user-name", - expected: { - path: "/endpoint/user-name", - params: { remote: "endpoint", user: "user-name" }, - }, - }, - { - input: "/foo.bar/user-name", - expected: { - path: "/foo.bar/user-name", - params: { remote: "foo.bar", user: "user-name" }, - }, - }, - ], - }, - { - path: "/:foo\\?", - tests: [ - { - input: "/route?", - expected: { path: "/route?", params: { foo: "route" } }, - }, - { - input: "/route", - expected: false, - }, - ], - }, - { - path: "{/:foo}+bar", - tests: [ - { - input: "/foobar", - expected: { path: "/foobar", params: { foo: ["foo"] } }, - }, - { - input: "/foo/bar", - expected: false, - }, - { - input: "/foo/barbar", - expected: false, - }, - ], - }, - { - path: "/{:pre}?baz", - tests: [ - { - input: "/foobaz", - expected: { path: "/foobaz", params: { pre: "foo" } }, - }, - { - input: "/baz", - expected: { path: "/baz", params: { pre: undefined } }, - }, - ], - }, - { - path: "/:foo\\(:bar\\)", - tests: [ - { - input: "/hello(world)", - expected: { - path: "/hello(world)", - params: { foo: "hello", bar: "world" }, - }, - }, - { - input: "/hello()", - expected: false, - }, - ], - }, - { - path: "/:foo\\({:bar}?\\)", - tests: [ - { - input: "/hello(world)", - expected: { - path: "/hello(world)", - params: { foo: "hello", bar: "world" }, + path: "/hello(world)", + params: { foo: "hello", bar: "world" }, }, }, { @@ -1377,27 +847,7 @@ export const MATCH_TESTS: MatchTestSet[] = [ ], }, { - path: "/:postType(video|audio|text){(\\+.+)}?", - tests: [ - { - input: "/video", - expected: { path: "/video", params: { postType: "video" } }, - }, - { - input: "/video+test", - expected: { - path: "/video+test", - params: { 0: "+test", postType: "video" }, - }, - }, - { - input: "/video+", - expected: false, - }, - ], - }, - { - path: "{/:foo}?{/:bar}?-ext", + path: "{/:foo}{/:bar}-ext", tests: [ { input: "/-ext", @@ -1428,7 +878,7 @@ export const MATCH_TESTS: MatchTestSet[] = [ ], }, { - path: "/:required{/:optional}?-ext", + path: "/:required{/:optional}-ext", tests: [ { input: "/foo-ext", @@ -1545,7 +995,7 @@ export const MATCH_TESTS: MatchTestSet[] = [ ], }, { - path: "mail{.:domain}?.com", + path: "mail{.:domain}.com", options: { delimiter: ".", }, @@ -1608,7 +1058,7 @@ export const MATCH_TESTS: MatchTestSet[] = [ * Prefixes. */ { - path: "{$:foo}{$:bar}?", + path: "$:foo{$:bar}", tests: [ { input: "$x", @@ -1621,20 +1071,7 @@ export const MATCH_TESTS: MatchTestSet[] = [ ], }, { - path: "{$:foo}+", - tests: [ - { - input: "$x", - expected: { path: "$x", params: { foo: ["x"] } }, - }, - { - input: "$x$y", - expected: { path: "$x$y", params: { foo: ["x", "y"] } }, - }, - ], - }, - { - path: "name{/:attr1}?{-:attr2}?{-:attr3}?", + path: "name{/:attr1}{-:attr2}{-:attr3}", tests: [ { input: "name", @@ -1678,108 +1115,12 @@ export const MATCH_TESTS: MatchTestSet[] = [ }, ], }, - { - path: "name{/:attrs;-}*", - tests: [ - { - input: "name", - expected: { path: "name", params: {} }, - }, - { - input: "name/1", - expected: { - path: "name/1", - params: { attrs: ["1"] }, - }, - }, - { - input: "name/1-2", - expected: { - path: "name/1-2", - params: { attrs: ["1", "2"] }, - }, - }, - { - input: "name/1-2-3", - expected: { - path: "name/1-2-3", - params: { attrs: ["1", "2", "3"] }, - }, - }, - { - input: "name/foo-bar/route", - expected: false, - }, - { - input: "name/test/route", - expected: false, - }, - ], - }, - - /** - * Nested parentheses. - */ - { - path: "/:test(\\d+(?:\\.\\d+)?)", - tests: [ - { - input: "/123", - expected: { path: "/123", params: { test: "123" } }, - }, - { - input: "/abc", - expected: false, - }, - { - input: "/123/abc", - expected: false, - }, - { - input: "/123.123", - expected: { path: "/123.123", params: { test: "123.123" } }, - }, - { - input: "/123.abc", - expected: false, - }, - ], - }, - { - path: "/:test((?!login)[^/]+)", - tests: [ - { - input: "/route", - expected: { path: "/route", params: { test: "route" } }, - }, - { - input: "/login", - expected: false, - }, - ], - }, /** * https://github.com/pillarjs/path-to-regexp/issues/206 */ { - path: "/user{(s)}?/:user", - tests: [ - { - input: "/user/123", - expected: { path: "/user/123", params: { user: "123" } }, - }, - { - input: "/users/123", - expected: { - path: "/users/123", - params: { 0: "s", user: "123" }, - }, - }, - ], - }, - { - path: "/user{s}?/:user", + path: "/user{s}/:user", tests: [ { input: "/user/123", @@ -1793,250 +1134,176 @@ export const MATCH_TESTS: MatchTestSet[] = [ }, /** - * https://github.com/pillarjs/path-to-regexp/pull/270 - */ - { - path: "/files{/:path}*{.:ext}*", - tests: [ - { - input: "/files/hello/world.txt", - expected: { - path: "/files/hello/world.txt", - params: { path: ["hello", "world"], ext: ["txt"] }, - }, - }, - { - input: "/files/hello/world.txt.png", - expected: { - path: "/files/hello/world.txt.png", - params: { path: ["hello", "world"], ext: ["txt", "png"] }, - }, - }, - { - input: "/files/my/photo.jpg/gif", - expected: false, - }, - ], - }, - { - path: "/files{/:path}*{.:ext}?", - tests: [ - { - input: "/files/hello/world.txt", - expected: { - path: "/files/hello/world.txt", - params: { path: ["hello", "world"], ext: "txt" }, - }, - }, - { - input: "/files/my/photo.jpg/gif", - expected: false, - }, - ], - }, - { - path: "#/*", - tests: [ - { - input: "#/", - expected: { path: "#/", params: {} }, - }, - ], - }, - { - path: "/foo{/:bar}*", - tests: [ - { - input: "/foo/test1/test2", - expected: { - path: "/foo/test1/test2", - params: { bar: ["test1", "test2"] }, - }, - }, - ], - }, - { - path: "/entity/:id/*", - tests: [ - { - input: "/entity/foo", - expected: false, - }, - { - input: "/entity/foo/", - expected: { path: "/entity/foo/", params: { id: "foo" } }, - }, - ], - }, - { - path: "/test/*", - tests: [ - { - input: "/test", - expected: false, - }, - { - input: "/test/", - expected: { path: "/test/", params: {} }, - }, - { - input: "/test/route", - expected: { path: "/test/route", params: { "0": ["route"] } }, - }, - { - input: "/test/route/nested", - expected: { - path: "/test/route/nested", - params: { "0": ["route", "nested"] }, - }, - }, - ], - }, - - /** - * Asterisk wildcard. + * Wildcard. */ { - path: "/*", + path: "/*path", tests: [ { input: "/", - expected: { path: "/", params: { "0": undefined } }, + expected: false, }, { input: "/route", - expected: { path: "/route", params: { "0": ["route"] } }, + expected: { path: "/route", params: { path: ["route"] } }, }, { input: "/route/nested", expected: { path: "/route/nested", - params: { "0": ["route", "nested"] }, + params: { path: ["route", "nested"] }, }, }, ], }, { - path: "*", + path: "*path", tests: [ { input: "/", - expected: { path: "/", params: { "0": ["", ""] } }, + expected: { path: "/", params: { path: ["", ""] } }, }, { input: "/test", - expected: { path: "/test", params: { "0": ["", "test"] } }, + expected: { path: "/test", params: { path: ["", "test"] } }, }, ], }, { - path: "*", + path: "*path", options: { decode: false }, tests: [ { input: "/", - expected: { path: "/", params: { "0": "/" } }, + expected: { path: "/", params: { path: "/" } }, }, { input: "/test", - expected: { path: "/test", params: { "0": "/test" } }, + expected: { path: "/test", params: { path: "/test" } }, }, ], }, { - path: "/*.:ext", + path: "/*path.:ext", tests: [ { input: "/test.html", expected: { path: "/test.html", - params: { "0": ["test"], ext: "html" }, + params: { path: ["test"], ext: "html" }, }, }, { input: "/test.html/nested", expected: false, }, + { + input: "/test.html/nested.json", + expected: { + path: "/test.html/nested.json", + params: { path: ["test.html", "nested"], ext: "json" }, + }, + }, ], }, { - path: "/*{.:ext}?", + path: "/:path.*ext", tests: [ { input: "/test.html", expected: { path: "/test.html", - params: { "0": ["test.html"], ext: undefined }, + params: { path: "test", ext: ["html"] }, }, }, { input: "/test.html/nested", expected: { - params: { - "0": ["test.html", "nested"], - }, path: "/test.html/nested", + params: { path: "test", ext: ["html", "nested"] }, + }, + }, + { + input: "/test.html/nested.json", + expected: { + path: "/test.html/nested.json", + params: { path: "test", ext: ["html", "nested.json"] }, }, }, ], }, { - path: "/*{.:ext}*", + path: "/*path{.:ext}", tests: [ { input: "/test.html", expected: { path: "/test.html", - params: { "0": ["test.html"], ext: undefined }, + params: { path: ["test"], ext: "html" }, }, }, { input: "/test.html/nested", expected: { params: { - "0": ["test.html", "nested"], + path: ["test.html", "nested"], }, path: "/test.html/nested", }, }, ], }, - - /** - * Longer prefix. - */ { - path: "/:foo{/test/:bar}?", + path: "/entity/:id/*path", tests: [ { - input: "/route", - expected: { path: "/route", params: { foo: "route" } }, + input: "/entity/foo", + expected: false, }, { - input: "/route/test/again", + input: "/entity/foo/path", expected: { - path: "/route/test/again", - params: { foo: "route", bar: "again" }, + path: "/entity/foo/path", + params: { id: "foo", path: ["path"] }, + }, + }, + ], + }, + { + path: "/*foo/:bar/*baz", + tests: [ + { + input: "/x/y/z", + expected: { + path: "/x/y/z", + params: { foo: ["x"], bar: "y", baz: ["z"] }, + }, + }, + { + input: "/1/2/3/4/5", + expected: { + path: "/1/2/3/4/5", + params: { foo: ["1", "2", "3"], bar: "4", baz: ["5"] }, }, }, ], }, /** - * Prefix and suffix as separator. + * Longer prefix. */ { - path: "/{<:foo>}+", + path: "/:foo{/test/:bar}", tests: [ { - input: "/", - expected: { path: "/", params: { foo: ["test"] } }, + input: "/route", + expected: { path: "/route", params: { foo: "route" } }, }, { - input: "/", + input: "/route/test/again", expected: { - path: "/", - params: { foo: ["test", "again"] }, + path: "/route/test/again", + params: { foo: "route", bar: "again" }, }, }, ], @@ -2046,7 +1313,7 @@ export const MATCH_TESTS: MatchTestSet[] = [ * Backtracking tests. */ { - path: "{:foo/}?{:bar.}?", + path: "{:foo/}{:bar.}", tests: [ { input: "", @@ -2066,7 +1333,7 @@ export const MATCH_TESTS: MatchTestSet[] = [ ], }, { - path: "/abc{abc:foo}?", + path: "/abc{abc:foo}", tests: [ { input: "/abc", @@ -2094,19 +1361,28 @@ export const MATCH_TESTS: MatchTestSet[] = [ ], }, { - path: "/:foo{abc:bar}?", + path: "/:foo{abc:bar}", tests: [ { input: "/abc", - expected: false, + expected: { + params: { foo: "abc" }, + path: "/abc", + }, }, { input: "/abcabc", - expected: false, + expected: { + params: { foo: "abcabc" }, + path: "/abcabc", + }, }, { input: "/abcabc123", - expected: false, + expected: { + params: { foo: "abc", bar: "123" }, + path: "/abcabc123", + }, }, { input: "/acb", @@ -2116,10 +1392,17 @@ export const MATCH_TESTS: MatchTestSet[] = [ }, }, { - input: "/acbabc123", + input: "/123", + expected: { + path: "/123", + params: { foo: "123" }, + }, + }, + { + input: "/123abcabc", expected: { - path: "/acbabc123", - params: { foo: "acb", bar: "123" }, + path: "/123abcabc", + params: { foo: "123abcabc" }, }, }, ], @@ -2137,20 +1420,14 @@ export const MATCH_TESTS: MatchTestSet[] = [ }, { input: "/abcabc123", - expected: false, - }, - ], - }, - { - path: "/:foo(.*){.:ext}?", - tests: [ - { - input: "/abc", - expected: { path: "/abc", params: { foo: "abc" } }, + expected: { + path: "/abcabc123", + params: { foo: "abc", bar: "123" }, + }, }, { - input: "/abc.txt", - expected: { path: "/abc.txt", params: { foo: "abc.txt" } }, + input: "/123abcabc", + expected: false, }, ], }, @@ -2186,6 +1463,22 @@ export const MATCH_TESTS: MatchTestSet[] = [ }, ], }, + { + path: "/:foo{|:bar|}", + tests: [ + { + input: "/hello|world|", + expected: { + path: "/hello|world|", + params: { foo: "hello", bar: "world" }, + }, + }, + { + input: "/hello||", + expected: { path: "/hello||", params: { foo: "hello||" } }, + }, + ], + }, { path: ":foo\\@:bar", tests: [ @@ -2204,7 +1497,7 @@ export const MATCH_TESTS: MatchTestSet[] = [ * Multi character delimiters. */ { - path: "%25:foo{%25:bar}?", + path: "%25:foo{%25:bar}", options: { delimiter: "%25", }, diff --git a/src/index.bench.ts b/src/index.bench.ts new file mode 100644 index 0000000..9d39219 --- /dev/null +++ b/src/index.bench.ts @@ -0,0 +1,42 @@ +import { bench } from "vitest"; +import { match } from "./index.js"; + +const PATHS: string[] = [ + "/xyz", + "/user", + "/user/123", + "/" + "a".repeat(32_000), + "/-" + "-a".repeat(8_000) + "/-", + "/||||\x00|" + "||".repeat(27387) + "|\x00".repeat(27387) + "/||/", +]; + +const STATIC_PATH_MATCH = match("/user"); +const SIMPLE_PATH_MATCH = match("/user/:id"); +const MULTI_SEGMENT_MATCH = match("/:x/:y"); +const MULTI_PATTERN_MATCH = match("/:x-:y"); +const TRICKY_PATTERN_MATCH = match("/:foo|:bar|"); +const ASTERISK_MATCH = match("/*foo"); + +bench("static path", () => { + for (const path of PATHS) STATIC_PATH_MATCH(path); +}); + +bench("simple path", () => { + for (const path of PATHS) SIMPLE_PATH_MATCH(path); +}); + +bench("multi segment", () => { + for (const path of PATHS) MULTI_SEGMENT_MATCH(path); +}); + +bench("multi pattern", () => { + for (const path of PATHS) MULTI_PATTERN_MATCH(path); +}); + +bench("tricky pattern", () => { + for (const path of PATHS) TRICKY_PATTERN_MATCH(path); +}); + +bench("asterisk", () => { + for (const path of PATHS) ASTERISK_MATCH(path); +}); diff --git a/src/index.spec.ts b/src/index.spec.ts index ef019c9..c6da631 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -6,89 +6,86 @@ import { PARSER_TESTS, COMPILE_TESTS, MATCH_TESTS } from "./cases.spec.js"; * Dynamically generate the entire test suite. */ describe("path-to-regexp", () => { - describe("arguments", () => { - it("should throw on non-capturing pattern", () => { - expect(() => match("/:foo(?:\\d+(\\.\\d+)?)")).toThrow( + describe("parse errors", () => { + it("should throw on unbalanced group", () => { + expect(() => parse("/{:foo,")).toThrow( new TypeError( - 'Pattern cannot start with "?" at 6: https://git.new/pathToRegexpError', + "Unexpected END at 7, expected }: https://git.new/pathToRegexpError", ), ); }); - - it("should throw on nested capturing group", () => { - expect(() => match("/:foo(\\d+(\\.\\d+)?)")).toThrow( + it("should throw on nested unbalanced group", () => { + expect(() => parse("/{:foo/{x,y}")).toThrow( new TypeError( - "Capturing groups are not allowed at 9: https://git.new/pathToRegexpError", + "Unexpected END at 12, expected }: https://git.new/pathToRegexpError", ), ); }); - it("should throw on unbalanced pattern", () => { - expect(() => match("/:foo(abc")).toThrow( + it("should throw on missing param name", () => { + expect(() => parse("/:/")).toThrow( new TypeError( - "Unbalanced pattern at 5: https://git.new/pathToRegexpError", + "Missing parameter name at 2: https://git.new/pathToRegexpError", ), ); }); - it("should throw on unmatched )", function () { - expect(() => match("/:fooab)c")).toThrow( - new TypeError("Unmatched ) at 7: https://git.new/pathToRegexpError"), - ); - }); - - it("should throw on unmatched ) after other patterns", function () { - expect(() => match("/:test(\\w+)/:foo(\\d+))")).toThrow( - new TypeError("Unmatched ) at 21: https://git.new/pathToRegexpError"), - ); - }); - - it("should throw on missing pattern", () => { - expect(() => match("/:foo()")).toThrow( + it("should throw on missing wildcard name", () => { + expect(() => parse("/*/")).toThrow( new TypeError( - "Missing pattern at 5: https://git.new/pathToRegexpError", + "Missing parameter name at 2: https://git.new/pathToRegexpError", ), ); }); - it("should throw on missing name", () => { - expect(() => match("/:(test)")).toThrow( + it("should throw on unterminated quote", () => { + expect(() => parse('/:"foo')).toThrow( new TypeError( - "Missing parameter name at 2: https://git.new/pathToRegexpError", + "Unterminated quote at 2: https://git.new/pathToRegexpError", ), ); }); + }); - it("should throw on nested groups", () => { - expect(() => match("/{a{b:foo}}")).toThrow( - new TypeError( - "Unexpected { at 3, expected }: https://git.new/pathToRegexpError", - ), - ); + describe("compile errors", () => { + it("should throw when a param is missing", () => { + const toPath = compile("/a/:b/c"); + + expect(() => { + toPath(); + }).toThrow(new TypeError("Missing parameters: b")); }); - it("should throw on repeat parameters without a separator", () => { - expect(() => match("{:x}*")).toThrow( - new TypeError( - `Missing separator for "x": https://git.new/pathToRegexpError`, - ), - ); + it("should throw when expecting a repeated value", () => { + const toPath = compile("/*foo"); + + expect(() => { + toPath({ foo: [] }); + }).toThrow(new TypeError('Expected "foo" to be a non-empty array')); }); - it("should throw on unterminated quote", () => { - expect(() => match('/:"foo')).toThrow( - new TypeError( - "Unterminated quote at 2: https://git.new/pathToRegexpError", - ), - ); + it("should throw when param gets an array", () => { + const toPath = compile("/:foo"); + + expect(() => { + toPath({ foo: [] }); + }).toThrow(new TypeError('Expected "foo" to be a string')); }); - it("should throw on invalid *", () => { - expect(() => match("/:foo*")).toThrow( - new TypeError( - "Unexpected * at 5, you probably want `/*` or `{/:foo}*`: https://git.new/pathToRegexpError", - ), - ); + it("should throw when a wildcard is not an array", () => { + const toPath = compile("/*foo"); + + expect(() => { + toPath({ foo: "a" }); + }).toThrow(new TypeError('Expected "foo" to be a non-empty array')); + }); + + it("should throw when a wildcard array value is not a string", () => { + const toPath = compile("/*foo"); + + expect(() => { + toPath({ foo: [1, "a"] as any }); + }).toThrow(new TypeError('Expected "foo/0" to be a string')); }); }); @@ -126,75 +123,4 @@ describe("path-to-regexp", () => { }); }, ); - - describe("compile errors", () => { - it("should throw when a required param is undefined", () => { - const toPath = compile("/a/:b/c"); - - expect(() => { - toPath(); - }).toThrow(new TypeError('Expected "b" to be a string')); - }); - - it("should throw when it does not match the pattern", () => { - const toPath = compile("/:foo(\\d+)"); - - expect(() => { - toPath({ foo: "abc" }); - }).toThrow(new TypeError('Invalid value for "foo": "abc"')); - }); - - it("should throw when expecting a repeated value", () => { - const toPath = compile("{/:foo}+"); - - expect(() => { - toPath({ foo: [] }); - }).toThrow(new TypeError('Invalid value for "foo": ""')); - }); - - it("should throw when not expecting a repeated value", () => { - const toPath = compile("/:foo"); - - expect(() => { - toPath({ foo: [] }); - }).toThrow(new TypeError('Expected "foo" to be a string')); - }); - - it("should throw when a repeated param is not an array", () => { - const toPath = compile("{/:foo}+"); - - expect(() => { - toPath({ foo: "a" }); - }).toThrow(new TypeError('Expected "foo" to be an array')); - }); - - it("should throw when an array value is not a string", () => { - const toPath = compile("{/:foo}+"); - - expect(() => { - toPath({ foo: [1, "a"] as any }); - }).toThrow(new TypeError('Expected "foo/0" to be a string')); - }); - - it("should throw when repeated value does not match", () => { - const toPath = compile("{/:foo(\\d+)}+"); - - expect(() => { - toPath({ foo: ["1", "2", "3", "a"] }); - }).toThrow(new TypeError('Invalid value for "foo": "/1/2/3/a"')); - }); - }); }); - -/** - * Execute a regular expression and return a flat array for comparison. - * - * @param {RegExp} re - * @param {String} str - * @return {Array} - */ -function exec(re: RegExp, str: string) { - const match = re.exec(str); - - return match && Array.prototype.slice.call(match); -} diff --git a/src/index.ts b/src/index.ts index 2692df0..a63e365 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,24 +15,13 @@ export type Encode = (value: string) => string; export type Decode = (value: string) => string; export interface ParseOptions { - /** - * The default delimiter for segments. (default: `'/'`) - */ - delimiter?: string; /** * A function for encoding input strings. */ encodePath?: Encode; } -export interface PathOptions { - /** - * Regexp will be case sensitive. (default: `false`) - */ - sensitive?: boolean; -} - -export interface MatchOptions extends PathOptions { +export interface MatchOptions { /** * Function for decoding strings for params, or `false` to disable entirely. (default: `decodeURIComponent`) */ @@ -41,35 +30,47 @@ export interface MatchOptions extends PathOptions { * Matches the path completely without trailing characters. (default: `true`) */ end?: boolean; -} - -export interface CompileOptions extends PathOptions { /** - * Verifies the function is producing a valid path. (default: `true`) + * Allows optional trailing delimiter to match. (default: `true`) + */ + trailing?: boolean; + /** + * Match will be case sensitive. (default: `false`) + */ + sensitive?: boolean; + /** + * The default delimiter for segments. (default: `'/'`) */ - validate?: boolean; + delimiter?: string; +} + +export interface CompileOptions { /** * Function for encoding input strings for output into the path, or `false` to disable entirely. (default: `encodeURIComponent`) */ encode?: Encode | false; + /** + * The default delimiter for segments. (default: `'/'`) + */ + delimiter?: string; } type TokenType = | "{" | "}" - | ";" - | "*" - | "+" - | "?" - | "NAME" - | "PATTERN" + | "WILDCARD" + | "PARAM" | "CHAR" | "ESCAPED" | "END" - // Reserved for use. - | "!" - | "@" - | ","; + // Reserved for use or ambiguous due to past use. + | "(" + | ")" + | "[" + | "]" + | "+" + | "?" + | "!"; /** * Tokenizer results. @@ -81,153 +82,120 @@ interface LexToken { } const SIMPLE_TOKENS: Record = { - "!": "!", - "@": "@", - ";": ";", - ",": ",", - "*": "*", - "+": "+", - "?": "?", + // Groups. "{": "{", "}": "}", + // Reserved. + "(": "(", + ")": ")", + "[": "[", + "]": "]", + "+": "+", + "?": "?", + "!": "!", }; +/** + * Escape a regular expression string. + */ +function escape(str: string) { + return str.replace(/[.+*?^${}()[\]|/\\]/g, "\\$&"); +} + +/** + * Get the flags for a regexp from the options. + */ +function toFlags(options: { sensitive?: boolean }) { + return options.sensitive ? "s" : "is"; +} + /** * Tokenize input string. */ -function lexer(str: string) { +function* lexer(str: string): Generator { const chars = [...str]; - const tokens: LexToken[] = []; let i = 0; - while (i < chars.length) { - const value = chars[i]; - const type = SIMPLE_TOKENS[value]; - - if (type) { - tokens.push({ type, index: i++, value }); - continue; - } + function name() { + let value = ""; - if (value === "\\") { - tokens.push({ type: "ESCAPED", index: i++, value: chars[i++] }); - continue; - } - - if (value === ":") { - let name = ""; - - if (ID_START.test(chars[++i])) { - name += chars[i]; - while (ID_CONTINUE.test(chars[++i])) { - name += chars[i]; - } - } else if (chars[i] === '"') { - let pos = i; - - while (i < chars.length) { - if (chars[++i] === '"') { - i++; - pos = 0; - break; - } - - if (chars[i] === "\\") { - name += chars[++i]; - } else { - name += chars[i]; - } - } - - if (pos) { - throw new TypeError(`Unterminated quote at ${pos}: ${DEBUG_URL}`); - } - } - - if (!name) { - throw new TypeError(`Missing parameter name at ${i}: ${DEBUG_URL}`); - } - - tokens.push({ type: "NAME", index: i, value: name }); - continue; - } - - if (value === "(") { - const pos = i++; - let count = 1; - let pattern = ""; - - if (chars[i] === "?") { - throw new TypeError( - `Pattern cannot start with "?" at ${i}: ${DEBUG_URL}`, - ); + if (ID_START.test(chars[++i])) { + value += chars[i]; + while (ID_CONTINUE.test(chars[++i])) { + value += chars[i]; } + } else if (chars[i] === '"') { + let pos = i; while (i < chars.length) { - if (chars[i] === "\\") { - pattern += chars[i++] + chars[i++]; - continue; + if (chars[++i] === '"') { + i++; + pos = 0; + break; } - if (chars[i] === ")") { - count--; - if (count === 0) { - i++; - break; - } - } else if (chars[i] === "(") { - count++; - if (chars[i + 1] !== "?") { - throw new TypeError( - `Capturing groups are not allowed at ${i}: ${DEBUG_URL}`, - ); - } + if (chars[i] === "\\") { + value += chars[++i]; + } else { + value += chars[i]; } - - pattern += chars[i++]; } - if (count) { - throw new TypeError(`Unbalanced pattern at ${pos}: ${DEBUG_URL}`); + if (pos) { + throw new TypeError(`Unterminated quote at ${pos}: ${DEBUG_URL}`); } - - if (!pattern) { - throw new TypeError(`Missing pattern at ${pos}: ${DEBUG_URL}`); - } - - tokens.push({ type: "PATTERN", index: i, value: pattern }); - continue; } - if (value === ")") { - throw new TypeError(`Unmatched ) at ${i}: ${DEBUG_URL}`); + if (!value) { + throw new TypeError(`Missing parameter name at ${i}: ${DEBUG_URL}`); } - tokens.push({ type: "CHAR", index: i, value: chars[i++] }); + return value; } - tokens.push({ type: "END", index: i, value: "" }); + while (i < chars.length) { + const value = chars[i]; + const type = SIMPLE_TOKENS[value]; - return new Iter(tokens); + if (type) { + yield { type, index: i++, value }; + } else if (value === "\\") { + yield { type: "ESCAPED", index: i++, value: chars[i++] }; + } else if (value === ":") { + const value = name(); + yield { type: "PARAM", index: i, value }; + } else if (value === "*") { + const value = name(); + yield { type: "WILDCARD", index: i, value }; + } else { + yield { type: "CHAR", index: i, value: chars[i++] }; + } + } + + return { type: "END", index: i, value: "" }; } class Iter { - index = 0; + #peek?: LexToken; - constructor(private tokens: LexToken[]) {} + constructor(private tokens: Generator) {} peek(): LexToken { - return this.tokens[this.index]; + if (!this.#peek) { + const next = this.tokens.next(); + this.#peek = next.value; + } + return this.#peek; } - tryConsume(type: LexToken["type"]): string | undefined { + tryConsume(type: TokenType): string | undefined { const token = this.peek(); if (token.type !== type) return; - this.index++; + this.#peek = undefined; // Reset after consumed. return token.value; } - consume(type: LexToken["type"]): string { + consume(type: TokenType): string { const value = this.tryConsume(type); if (value !== undefined) return value; const { type: nextType, index } = this.peek(); @@ -244,223 +212,216 @@ class Iter { } return result; } +} - modifier(): string | undefined { - return this.tryConsume("?") || this.tryConsume("*") || this.tryConsume("+"); - } +/** + * Plain text. + */ +export interface Text { + type: "text"; + value: string; } /** - * Tokenized path instance. Can we passed around instead of string. + * A parameter designed to match arbitrary text within a segment. + */ +export interface Parameter { + type: "param"; + name: string; +} + +/** + * A wildcard parameter designed to match multiple segments. + */ +export interface Wildcard { + type: "wildcard"; + name: string; +} + +/** + * A set of possible tokens to expand when matching. + */ +export interface Group { + type: "group"; + tokens: Token[]; +} + +/** + * A sequence of path match characters. + */ +export type Token = Text | Parameter | Wildcard | Group; + +/** + * Tokenized path instance. */ export class TokenData { - constructor( - public readonly tokens: Token[], - public readonly delimiter: string, - ) {} + constructor(public readonly tokens: Token[]) {} } /** * Parse a string for the raw tokens. */ export function parse(str: string, options: ParseOptions = {}): TokenData { - const { encodePath = NOOP_VALUE, delimiter = encodePath(DEFAULT_DELIMITER) } = - options; - const tokens: Token[] = []; - const it = lexer(str); - let key = 0; - - do { - const path = it.text(); - if (path) tokens.push(encodePath(path)); - - const name = it.tryConsume("NAME"); - const pattern = it.tryConsume("PATTERN"); - - if (name || pattern) { - tokens.push({ - name: name || String(key++), - pattern, - }); - - const next = it.peek(); - if (next.type === "*") { - throw new TypeError( - `Unexpected * at ${next.index}, you probably want \`/*\` or \`{/:foo}*\`: ${DEBUG_URL}`, - ); + const { encodePath = NOOP_VALUE } = options; + const it = new Iter(lexer(str)); + + function consume(endType: TokenType): Token[] { + const tokens: Token[] = []; + + while (true) { + const path = it.text(); + if (path) tokens.push({ type: "text", value: encodePath(path) }); + + const param = it.tryConsume("PARAM"); + if (param) { + tokens.push({ + type: "param", + name: param, + }); + continue; } - continue; - } + const wildcard = it.tryConsume("WILDCARD"); + if (wildcard) { + tokens.push({ + type: "wildcard", + name: wildcard, + }); + continue; + } - const asterisk = it.tryConsume("*"); - if (asterisk) { - tokens.push({ - name: String(key++), - pattern: `${negate(delimiter)}*`, - modifier: "*", - separator: delimiter, - }); - continue; - } + const open = it.tryConsume("{"); + if (open) { + tokens.push({ + type: "group", + tokens: consume("}"), + }); + continue; + } - const open = it.tryConsume("{"); - if (open) { - const prefix = it.text(); - const name = it.tryConsume("NAME"); - const pattern = it.tryConsume("PATTERN"); - const suffix = it.text(); - const separator = it.tryConsume(";") && it.text(); - - it.consume("}"); - - const modifier = it.modifier(); - - tokens.push({ - name: name || (pattern ? String(key++) : ""), - prefix: encodePath(prefix), - suffix: encodePath(suffix), - pattern, - modifier, - separator, - }); - continue; + it.consume(endType); + return tokens; } + } - it.consume("END"); - break; - } while (true); + const tokens = consume("END"); + return new TokenData(tokens); +} + +/** + * Transform tokens into a path building function. + */ +function $compile

( + data: TokenData, + options: CompileOptions, +): PathFunction

{ + const { encode = encodeURIComponent, delimiter = DEFAULT_DELIMITER } = + options; + const fn = tokensToFunction(data.tokens, delimiter, encode); - return new TokenData(tokens, delimiter); + return function path(data: P = {} as P) { + const [path, ...missing] = fn(data); + if (missing.length) { + throw new TypeError(`Missing parameters: ${missing.join(", ")}`); + } + return path; + }; } /** * Compile a string to a template function for the path. */ export function compile

( - path: string, + path: Path, options: CompileOptions & ParseOptions = {}, ) { - return $compile

(parse(path, options), options); + return $compile

( + path instanceof TokenData ? path : parse(path, options), + options, + ); } export type ParamData = Partial>; export type PathFunction

= (data?: P) => string; -/** - * Check if a key repeats. - */ -export function isRepeat(key: Key) { - return key.modifier === "+" || key.modifier === "*"; -} +function tokensToFunction( + tokens: Token[], + delimiter: string, + encode: Encode | false, +) { + const encoders = tokens.map((token) => + tokenToFunction(token, delimiter, encode), + ); -/** - * Check if a key is optional. - */ -export function isOptional(key: Key) { - return key.modifier === "?" || key.modifier === "*"; + return (data: ParamData) => { + const result: string[] = [""]; + + for (const encoder of encoders) { + const [value, ...extras] = encoder(data); + result[0] += value; + result.push(...extras); + } + + return result; + }; } /** * Convert a single token into a path building function. */ -function keyToFunction( - key: Key, +function tokenToFunction( + token: Token, + delimiter: string, encode: Encode | false, -): (data: ParamData) => string { - const encodeValue = encode || NOOP_VALUE; - const { prefix = "", suffix = "", separator = suffix + prefix } = key; - - if (encode && isRepeat(key)) { - const stringify = (value: string, index: number) => { - if (typeof value !== "string") { - throw new TypeError(`Expected "${key.name}/${index}" to be a string`); - } - return encodeValue(value); - }; - - const compile = (value: unknown) => { - if (!Array.isArray(value)) { - throw new TypeError(`Expected "${key.name}" to be an array`); - } +): (data: ParamData) => string[] { + if (token.type === "text") return () => [token.value]; - if (value.length === 0) return ""; + if (token.type === "group") { + const fn = tokensToFunction(token.tokens, delimiter, encode); - return prefix + value.map(stringify).join(separator) + suffix; + return (data) => { + const [value, ...missing] = fn(data); + if (!missing.length) return [value]; + return [""]; }; + } - if (isOptional(key)) { - return (data): string => { - const value = data[key.name]; - if (value == null) return ""; - return value.length ? compile(value) : ""; - }; - } + const encodeValue = encode || NOOP_VALUE; - return (data): string => { - const value = data[key.name]; - return compile(value); - }; - } + if (token.type === "wildcard" && encode !== false) { + return (data) => { + const value = data[token.name]; + if (value == null) return ["", token.name]; - const stringify = (value: unknown) => { - if (typeof value !== "string") { - throw new TypeError(`Expected "${key.name}" to be a string`); - } - return prefix + encodeValue(value) + suffix; - }; + if (!Array.isArray(value) || value.length === 0) { + throw new TypeError(`Expected "${token.name}" to be a non-empty array`); + } - if (isOptional(key)) { - return (data): string => { - const value = data[key.name]; - if (value == null) return ""; - return stringify(value); + return [ + value + .map((value, index) => { + if (typeof value !== "string") { + throw new TypeError( + `Expected "${token.name}/${index}" to be a string`, + ); + } + + return encodeValue(value); + }) + .join(delimiter), + ]; }; } - return (data): string => { - const value = data[key.name]; - return stringify(value); - }; -} + return (data) => { + const value = data[token.name]; + if (value == null) return ["", token.name]; -/** - * Transform tokens into a path building function. - */ -export function $compile

( - data: TokenData, - options: CompileOptions, -): PathFunction

{ - const { encode = encodeURIComponent, validate = true } = options; - const flags = toFlags(options); - const sources = toRegExpSource(data, []); - - // Compile all the tokens into regexps. - const encoders: Array<(data: ParamData) => string> = data.tokens.map( - (token, index) => { - if (typeof token === "string") return () => token; - - const fn = keyToFunction(token, encode); - if (!validate) return fn; - - const validRe = new RegExp(`^${sources[index]}$`, flags); - - return (data) => { - const value = fn(data); - if (!validRe.test(value)) { - throw new TypeError( - `Invalid value for "${token.name}": ${JSON.stringify(value)}`, - ); - } - return value; - }; - }, - ); + if (typeof value !== "string") { + throw new TypeError(`Expected "${token.name}" to be a string`); + } - return function path(data: Record = {}) { - let path = ""; - for (const encoder of encoders) path += encoder(data); - return path; + return [encodeValue(value)]; }; } @@ -485,34 +446,38 @@ export type MatchFunction

= (path: string) => Match

; /** * Create path match function from `path-to-regexp` spec. */ -export function $match

( - data: TokenData, +function $match

( + data: TokenData[], options: MatchOptions = {}, ): MatchFunction

{ - const { decode = decodeURIComponent, end = true } = options; - const { delimiter } = data; - const keys: Key[] = []; + const { + decode = decodeURIComponent, + delimiter = DEFAULT_DELIMITER, + end = true, + trailing = true, + } = options; const flags = toFlags(options); - const sources = toRegExpSource(data, keys); - const re = new RegExp( - `^${sources.join("")}(?=${escape(delimiter)}|$)`, - flags, - ); - - const decoders = keys.map((key) => { - if (!decode) return NOOP_VALUE; + const sources: string[] = []; + const keys: Array = []; - if (isRepeat(key)) { - const { prefix = "", suffix = "", separator = suffix + prefix } = key; - return (value: string) => value.split(separator).map(decode); + for (const { tokens } of data) { + for (const seq of flatten(tokens, 0, [])) { + const regexp = sequenceToRegExp(seq, delimiter, keys); + sources.push(regexp); } + } - return decode; - }); + let pattern = `^(?:${sources.join("|")})`; + if (trailing) pattern += `(?:${escape(delimiter)}$)?`; + pattern += end ? "$" : `(?=${escape(delimiter)}|$)`; - const isValid = end - ? (a: string, b: string) => a.length === b.length - : () => true; + const re = new RegExp(pattern, flags); + + const decoders = keys.map((key) => { + if (decode === false) return NOOP_VALUE; + if (key.type === "param") return decode; + return (value: string) => value.split(delimiter).map(decode); + }); return Object.assign( function match(input: string) { @@ -520,7 +485,6 @@ export function $match

( if (!m) return false; const { 0: path } = m; - if (!isValid(input, path)) return false; const params = Object.create(null); for (let i = 1; i < m.length; i++) { @@ -537,132 +501,97 @@ export function $match

( ); } +export type Path = string | TokenData; + export function match

( - path: string, + path: Path | Path[], options: MatchOptions & ParseOptions = {}, ): MatchFunction

{ - return $match(parse(path, options), options); -} + const paths = Array.isArray(path) ? path : [path]; + const items = paths.map((path) => + path instanceof TokenData ? path : parse(path, options), + ); -/** - * Escape a regular expression string. - */ -function escape(str: string) { - return str.replace(/[.+*?^${}()[\]|/\\]/g, "\\$&"); + return $match(items, options); } /** - * Get the flags for a regexp from the options. + * Flattened token set. */ -function toFlags(options: { sensitive?: boolean }) { - return options.sensitive ? "s" : "is"; -} +type Flattened = Text | Parameter | Wildcard; /** - * A key is a capture group in the regex. + * Generate a flat list of sequence tokens from the given tokens. */ -export interface Key { - name: string; - prefix?: string; - suffix?: string; - pattern?: string; - modifier?: string; - separator?: string; -} +function* flatten( + tokens: Token[], + index: number, + init: Flattened[], +): Generator { + if (index === tokens.length) { + return yield init; + } -/** - * A token is a string (nothing special) or key metadata (capture group). - */ -export type Token = string | Key; + const token = tokens[index]; + + if (token.type === "group") { + const fork = init.slice(); + for (const seq of flatten(token.tokens, 0, fork)) { + yield* flatten(tokens, index + 1, seq); + } + } else { + init.push(token); + } + + yield* flatten(tokens, index + 1, init); +} /** - * Convert a token into a regexp string (re-used for path validation). + * Transform a flat sequence of tokens into a regular expression. */ -function toRegExpSource(data: TokenData, keys: Key[]): string[] { - const sources = Array(data.tokens.length); +function sequenceToRegExp( + tokens: Flattened[], + delimiter: string, + keys: Array, +): string { + let result = ""; let backtrack = ""; + let isSafeSegmentParam = true; - let i = data.tokens.length; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; - while (i--) { - const token = data.tokens[i]; - - if (typeof token === "string") { - backtrack = token; - sources[i] = escape(token); + if (token.type === "text") { + result += escape(token.value); + backtrack = token.value; + isSafeSegmentParam ||= token.value.includes(delimiter); continue; } - const { - prefix = "", - suffix = "", - separator = suffix + prefix, - modifier = "", - } = token; - - const pre = escape(prefix); - const post = escape(suffix); - - if (token.name) { - backtrack = suffix || backtrack; - keys.unshift(token); - - if (isRepeat(token)) { - if (!separator) { - throw new TypeError( - `Missing separator for "${token.name}": ${DEBUG_URL}`, - ); - } + if (token.type === "param" || token.type === "wildcard") { + if (!isSafeSegmentParam && !backtrack) { + throw new TypeError(`Missing text after "${token.name}": ${DEBUG_URL}`); + } - const mod = modifier === "*" ? "?" : ""; - const sep = escape(separator); - const pattern = - token.pattern || `${negate(data.delimiter, separator, backtrack)}+`; - - sources[i] = wrap( - pre, - `(?:${pattern})(?:${sep}(?:${pattern}))*`, - post, - mod, - ); + if (token.type === "param") { + result += `(${negate(delimiter, isSafeSegmentParam ? "" : backtrack)}+)`; } else { - sources[i] = wrap( - pre, - token.pattern || `${negate(data.delimiter, backtrack)}+`, - post, - modifier, - ); + result += `(.+)`; } - backtrack = prefix; - } else { - sources[i] = `(?:${pre}${post})${modifier}`; - backtrack = `${prefix}${suffix}`; + keys.push(token); + backtrack = ""; + isSafeSegmentParam = false; + continue; } } - return sources; + return result; } -function negate(...args: string[]) { - const values = args.sort().filter((value, index, array) => { - for (let i = 0; i < index; i++) { - const v = array[i]; - if (v.length && value.startsWith(v)) return false; - } - return value.length > 0; - }); - +function negate(delimiter: string, backtrack: string) { + const values = [delimiter, backtrack].filter(Boolean); const isSimple = values.every((value) => value.length === 1); if (isSimple) return `[^${escape(values.join(""))}]`; - return `(?:(?!${values.map(escape).join("|")}).)`; } - -function wrap(pre: string, pattern: string, post: string, modifier: string) { - if (pre || post) { - return `(?:${pre}(${pattern})${post})${modifier}`; - } - - return `(${pattern})${modifier}`; -} diff --git a/tsconfig.build.json b/tsconfig.build.json index d783ab3..3db8e88 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -3,5 +3,5 @@ "compilerOptions": { "types": [] }, - "exclude": ["src/**/*.spec.ts"] + "exclude": ["src/**/*.spec.ts", "src/**/*.bench.ts"] }