diff --git a/Readme.md b/Readme.md index dc267b7..263c0f7 100644 --- a/Readme.md +++ b/Readme.md @@ -17,208 +17,72 @@ npm install path-to-regexp --save ## Usage ```js -const { pathToRegexp, match, parse, compile } = require("path-to-regexp"); +const { match, compile, parse } = require("path-to-regexp"); -// pathToRegexp(path, options?) // match(path, options?) -// parse(path, options?) // compile(path, options?) +// parse(path, options?) ``` -### Path to regexp - -The `pathToRegexp` function returns a regular expression with `keys` as a property. It accepts the following arguments: - -- **path** A string. -- **options** _(optional)_ - - **sensitive** Regexp will be case sensitive. (default: `false`) - - **trailing** Allows optional trailing delimiter to match. (default: `true`) - - **strict** Verify patterns are valid and safe to use. (default: `false`, recommended: `true`) - - **end** Match to the end of the string. (default: `true`) - - **start** Match from the beginning of the string. (default: `true`) - - **loose** Allow the delimiter to be arbitrarily repeated, e.g. `/` or `///`. (default: `true`) - - **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) - -```js -const regexp = pathToRegexp("/foo/:bar"); -// regexp = /^\/+foo(?:\/+([^\/]+?))(?:\/+)?$/i -// keys = [{ name: 'bar', prefix: '', suffix: '', pattern: '', modifier: '' }] -``` - -**Please note:** The `RegExp` returned by `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 -The path argument is used to define parameters and populate keys. - -#### 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). - -```js -const regexp = pathToRegexp("/:foo/:bar"); -// keys = [{ name: 'foo', ... }, { name: 'bar', ... }] - -regexp.exec("/test/route"); -//=> [ '/test/route', 'test', 'route', index: 0 ] -``` - -##### 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: +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 regexpNumbers = pathToRegexp("/icon-:foo(\\d+).png"); -// keys = [{ name: 'foo', ... }] - -regexpNumbers.exec("/icon-123.png"); -//=> ['/icon-123.png', '123'] - -regexpNumbers.exec("/icon-abc.png"); -//=> null +const fn = match("/:foo/:bar"); -const regexpWord = pathToRegexp("/(user|u)"); -// keys = [{ name: 0, ... }] - -regexpWord.exec("/u"); -//=> ['/u', 'u'] - -regexpWord.exec("/users"); -//=> null +fn("/test/route"); +//=> { path: '/test/route', params: { foo: 'test', bar: 'route' } } ``` -**Tip:** Backslashes need to be escaped with another backslash in JavaScript strings. - -#### Unnamed parameters +### Wildcard -It is possible to define a parameter without a name. The name will be numerically indexed: +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 regexp = pathToRegexp("/:foo/(.*)"); -// keys = [{ name: 'foo', ... }, { name: '0', ... }] +const fn = match("/*splat"); -regexp.exec("/test/route"); -//=> [ '/test/route', 'test', 'route', index: 0 ] +fn("/bar/baz"); +//=> { path: '/bar/baz', params: { splat: [ 'bar', 'baz' ] } } ``` -##### Custom prefix and suffix +### Optional -Parameters can be wrapped in `{}` to create custom prefixes or suffixes for your segment: +Braces can be used to define parts of the path that are optional. ```js -const regexp = pathToRegexp("{/:attr1}?{-:attr2}?{-:attr3}?"); +const fn = match("/users{/:id}/delete"); -regexp.exec("/test"); -// => ['/test', 'test', undefined, undefined] +fn("/users/delete"); +//=> { path: '/users/delete', params: {} } -regexp.exec("/test-test"); -// => ['/test', 'test', 'test', undefined] +fn("/users/123/delete"); +//=> { path: '/users/123/delete', params: { id: '123' } } ``` -#### 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 regexp = pathToRegexp("/:foo{/:bar}?"); -// keys = [{ name: 'foo', ... }, { name: 'bar', prefix: '/', modifier: '?' }] - -regexp.exec("/test"); -//=> [ '/test', 'test', undefined, index: 0 ] - -regexp.exec("/test/route"); -//=> [ '/test/route', 'test', 'route', index: 0 ] -``` - -##### Zero or more - -Parameters can be suffixed with an asterisk (`*`) to denote a zero or more parameter matches. - -```js -const regexp = pathToRegexp("{/:foo}*"); -// keys = [{ name: 'foo', prefix: '/', modifier: '*' }] - -regexp.exec("/foo"); -//=> [ '/foo', "foo", index: 0 ] - -regexp.exec("/bar/baz"); -//=> [ '/bar/baz', 'bar/baz', index: 0 ] -``` +## Match -##### One or more +The `match` function returns a function for matching strings against a path: -Parameters can be suffixed with a plus sign (`+`) to denote a one or more parameter matches. - -```js -const regexp = pathToRegexp("{/:foo}+"); -// keys = [{ name: 'foo', prefix: '/', modifier: '+' }] - -regexp.exec("/"); -//=> null - -regexp.exec("/bar/baz"); -//=> [ '/bar/baz', 'bar/baz', index: 0 ] -``` - -##### Custom separator - -By default, parameters set the separator as the `prefix + suffix` of the token. Using `;` you can modify this: - -```js -const regexp = pathToRegexp("/name{/:parts;-}+"); - -regexp.exec("/name"); -//=> null - -regexp.exec("/bar/1-2-3"); -//=> [ '/name/1-2-3', '1-2-3', index: 0 ] -``` - -#### Wildcard - -A wildcard can also be used. It is roughly equivalent to `(.*)`. - -```js -const regexp = pathToRegexp("/*"); -// keys = [{ name: '0', pattern: '[^\\/]*', separator: '/', modifier: '*' }] - -regexp.exec("/"); -//=> [ '/', '', index: 0 ] - -regexp.exec("/bar/baz"); -//=> [ '/bar/baz', 'bar/baz', index: 0 ] -``` - -### Match - -The `match` function returns a function for transforming paths into parameters: - -- **path** A string. -- **options** _(optional)_ The same options as `pathToRegexp`, plus: - - **decode** Function for decoding strings for params, or `false` to disable entirely. (default: `decodeURIComponent`) +- **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("/user/:id"); - -fn("/user/123"); //=> { path: '/user/123', index: 0, params: { id: '123' } } -fn("/invalid"); //=> false -fn("/user/caf%C3%A9"); //=> { path: '/user/caf%C3%A9', index: 0, params: { id: 'café' } } +const fn = match("/foo/:bar"); ``` -**Note:** Setting `decode: false` disables the "splitting" behavior of repeated parameters, which is useful if you need the exactly matched parameter back. +**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) +## Compile ("Reverse" Path-To-RegExp) The `compile` function will return a function for transforming parameters into a valid path: - **path** A string. -- **options** _(optional)_ Similar to `pathToRegexp` (`delimiter`, `encodePath`, `sensitive`, and `loose`), plus: - - **validate** When `false` the function can produce an invalid (unmatched) path. (default: `true`) +- **options** (See [parse](#parse) for more options) - **encode** Function for encoding input strings for output into the path, or `false` to disable entirely. (default: `encodeURIComponent`) ```js @@ -227,53 +91,47 @@ 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 compiler, 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`. -- If matches are intended to be exact, you need to set `loose: false`, `trailing: false`, and `sensitive: true`. -- Enable `strict: true` to detect ReDOS issues. +- 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, such as emoji, consider using [encodeurl](https://github.com/pillarjs/encodeurl) for `encodePath`. ### Parse -A `parse` function is available and returns `TokenData`, the set of tokens and other metadata parsed from the input string. `TokenData` is can passed directly into `pathToRegexp`, `match`, and `compile`. It accepts only two options, `delimiter` and `encodePath`, which makes those options redundant in the above methods. +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`. -### Tokens +- **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)) -The `tokens` returned by `TokenData` is an array of strings or keys, represented as objects, with the following properties: +### Tokens -- `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 -In some applications, you may not be able to use the `path-to-regexp` syntax (e.g. file-based routing), but you can still use this library for `match`, `compile`, and `pathToRegexp` by building your own `TokenData` instance. For example: +In some applications, you may not be able to use the `path-to-regexp` syntax, but still want to use this library for `match` and `compile`. For example: ```js import { TokenData, match } from "path-to-regexp"; -const tokens = ["/", { name: "foo" }]; -const path = new TokenData(tokens, "/"); +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' } } @@ -283,31 +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 `+` +### Unexpected `?` or `+` + +In past releases, `?`, `*`, and `+` were used to denote optional or repeating parameters. As an alternative, try these: + +- 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}`. -In previous major versions `/` and `.` were used as implicit prefixes of parameters. So `/:key?` was implicitly `{/:key}?`. For example: +### Unexpected `(`, `)`, `[`, `]`, etc. -- `/:key?` → `{/:key}?` or `/:key*` → `{/:key}*` or `/:key+` → `{/:key}+` -- `.:key?` → `{.:key}?` or `.:key*` → `{.:key}*` or `.:key+` → `{.:key}+` -- `:key?` → `{:key}?` or `:key*` → `{:key}*` or `:key+` → `{:key}+` +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. `"\\("`. -### Unexpected `;` +### Missing parameter name -Used as a [custom separator](#custom-separator) for repeated parameters. +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"`. -### Unexpected `!`, `@`, or `,` +### Unterminated quote -These characters have been reserved for future use. +Parameter names can be wrapped in double quote characters, and this error means you forgot to close the quote character. ### 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/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..4aa608f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,5 @@ +# Security Policy + +## Security contact information + +To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. diff --git a/package-lock.json b/package-lock.json index 172848d..a582d4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "path-to-regexp", - "version": "7.1.0", + "version": "8.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "path-to-regexp", - "version": "7.1.0", + "version": "8.0.0", "license": "MIT", "devDependencies": { "@borderless/ts-scripts": "^0.15.0", diff --git a/package.json b/package.json index e1c220a..f6806a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "path-to-regexp", - "version": "7.1.0", + "version": "8.0.0", "description": "Express style path to RegExp utility", "keywords": [ "express", @@ -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 c675e71..841cd07 100644 --- a/scripts/redos.ts +++ b/scripts/redos.ts @@ -1,36 +1,22 @@ import { checkSync } from "recheck"; -import { pathToRegexp } from "../src/index.js"; +import { match } from "../src/index.js"; +import { MATCH_TESTS } from "../src/cases.spec.js"; -const TESTS = [ - "/abc{abc:foo}?", - "/:foo{abc:foo}?", - "{:attr1}?{:attr2/}?", - "{:attr1/}?{:attr2/}?", - "{:foo.}?{:bar.}?", - "{:foo([^\\.]+).}?{:bar.}?", - ":foo(a+):bar(b+)", -]; +let safe = 0; +let fail = 0; + +const TESTS = MATCH_TESTS.map((x) => x.path); for (const path of TESTS) { - try { - const re = pathToRegexp(path, { strict: true }); - const result = checkSync(re.source, re.flags); - if (result.status === "safe") { - console.log("Safe:", path, String(re)); - } else { - console.log("Fail:", path, String(re)); - } - } catch (err) { - try { - const re = pathToRegexp(path); - const result = checkSync(re.source, re.flags); - if (result.status === "safe") { - console.log("Invalid:", path, String(re)); - } else { - console.log("Pass:", path, String(re)); - } - } catch (err) { - console.log("Error:", path, err.message); - } + const { re } = match(path) as any; + const result = checkSync(re.source, re.flags); + if (result.status === "safe") { + safe++; + console.log("Safe:", path, String(re)); + } else { + fail++; + console.log("Fail:", path, String(re)); } } + +console.log("Safe:", safe, "Fail:", fail); diff --git a/src/cases.spec.ts b/src/cases.spec.ts index 23a1814..ef06e1f 100644 --- a/src/cases.spec.ts +++ b/src/cases.spec.ts @@ -1,5 +1,4 @@ import type { - Path, MatchOptions, Match, ParseOptions, @@ -16,7 +15,7 @@ export interface ParserTestSet { export interface CompileTestSet { path: string; - options?: CompileOptions; + options?: CompileOptions & ParseOptions; tests: Array<{ input: ParamData | undefined; expected: string | null; @@ -24,11 +23,10 @@ export interface CompileTestSet { } export interface MatchTestSet { - path: Path; - options?: MatchOptions; + path: string; + options?: MatchOptions & ParseOptions; tests: Array<{ input: string; - matches: (string | undefined)[] | null; expected: Match; }>; } @@ -36,23 +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" }], + path: '/:"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: [ + { type: "text", value: "/" }, + { type: "param", name: "123" }, + ], + }, + { + path: '/:"1\\"\\2\\"3"', + expected: [ + { type: "text", value: "/" }, + { type: "param", name: '1"2"3' }, + ], + }, + { + path: "/*path", + expected: [ + { type: "text", value: "/" }, + { type: "wildcard", name: "path" }, + ], }, ]; @@ -82,7 +113,7 @@ export const COMPILE_TESTS: CompileTestSet[] = [ ], }, { - path: "/:0", + path: '/:"0"', tests: [ { input: undefined, expected: null }, { input: {}, expected: null }, @@ -100,7 +131,6 @@ export const COMPILE_TESTS: CompileTestSet[] = [ }, { path: "/:test", - options: { validate: false }, tests: [ { input: undefined, expected: null }, { input: {}, expected: null }, @@ -110,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 }, @@ -118,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" }, @@ -139,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: "/" }, ], }, ]; @@ -205,10 +201,9 @@ export const MATCH_TESTS: MatchTestSet[] = [ tests: [ { input: "/", - matches: ["/"], - expected: { path: "/", index: 0, params: {} }, + expected: { path: "/", params: {} }, }, - { input: "/route", matches: null, expected: false }, + { input: "/route", expected: false }, ], }, { @@ -216,15 +211,13 @@ export const MATCH_TESTS: MatchTestSet[] = [ tests: [ { input: "/test", - matches: ["/test"], - expected: { path: "/test", index: 0, params: {} }, + expected: { path: "/test", params: {} }, }, - { input: "/route", matches: null, expected: false }, - { input: "/test/route", matches: null, expected: false }, + { input: "/route", expected: false }, + { input: "/test/route", expected: false }, { input: "/test/", - matches: ["/test/"], - expected: { path: "/test/", index: 0, params: {} }, + expected: { path: "/test/", params: {} }, }, ], }, @@ -233,15 +226,13 @@ export const MATCH_TESTS: MatchTestSet[] = [ tests: [ { input: "/test/", - matches: ["/test/"], - expected: { path: "/test/", index: 0, params: {} }, + expected: { path: "/test/", params: {} }, }, - { input: "/route", matches: null, expected: false }, - { input: "/test", matches: null, expected: false }, + { input: "/route", expected: false }, + { input: "/test", expected: false }, { input: "/test//", - matches: ["/test//"], - expected: { path: "/test//", index: 0, params: {} }, + expected: { path: "/test//", params: {} }, }, ], }, @@ -250,60 +241,51 @@ export const MATCH_TESTS: MatchTestSet[] = [ tests: [ { input: "/route", - matches: ["/route", "route"], - expected: { path: "/route", index: 0, params: { test: "route" } }, + expected: { path: "/route", params: { test: "route" } }, }, { input: "/route/", - matches: ["/route/", "route"], - expected: { path: "/route/", index: 0, params: { test: "route" } }, + expected: { path: "/route/", params: { test: "route" } }, }, { input: "/route.json", - matches: ["/route.json", "route.json"], expected: { path: "/route.json", - index: 0, params: { test: "route.json" }, }, }, { input: "/route.json/", - matches: ["/route.json/", "route.json"], expected: { path: "/route.json/", - index: 0, params: { test: "route.json" }, }, }, { input: "/route/test", - matches: null, expected: false, }, - { - input: "///route", - matches: ["///route", "route"], - expected: { path: "///route", index: 0, params: { test: "route" } }, - }, { input: "/caf%C3%A9", - matches: ["/caf%C3%A9", "caf%C3%A9"], expected: { path: "/caf%C3%A9", - index: 0, params: { test: "café" }, }, }, { input: "/;,:@&=+$-_.!~*()", - matches: ["/;,:@&=+$-_.!~*()", ";,:@&=+$-_.!~*()"], expected: { path: "/;,:@&=+$-_.!~*()", - index: 0, params: { test: ";,:@&=+$-_.!~*()" }, }, }, + { + input: "/param%2523", + expected: { + path: "/param%2523", + params: { test: "param%23" }, + }, + }, ], }, @@ -318,10 +300,9 @@ export const MATCH_TESTS: MatchTestSet[] = [ tests: [ { input: "/test", - matches: ["/test"], - expected: { path: "/test", index: 0, params: {} }, + expected: { path: "/test", params: {} }, }, - { input: "/TEST", matches: null, expected: false }, + { input: "/TEST", expected: false }, ], }, { @@ -332,126 +313,9 @@ export const MATCH_TESTS: MatchTestSet[] = [ tests: [ { input: "/TEST", - matches: ["/TEST"], - expected: { path: "/TEST", index: 0, params: {} }, - }, - { input: "/test", matches: null, expected: false }, - ], - }, - - /** - * Non-trailing mode. - */ - { - path: "/test", - options: { - trailing: false, - }, - tests: [ - { - input: "/test", - matches: ["/test"], - expected: { path: "/test", index: 0, params: {} }, - }, - { - input: "/test/", - matches: null, - expected: false, - }, - { - input: "/test/route", - matches: null, - expected: false, - }, - ], - }, - { - path: "/test/", - options: { - trailing: false, - }, - tests: [ - { - input: "/test/", - matches: ["/test/"], - expected: { path: "/test/", index: 0, params: {} }, - }, - { - input: "/test", - matches: null, - expected: false, - }, - { - input: "/test//", - matches: ["/test//"], - expected: { path: "/test//", index: 0, params: {} }, - }, - ], - }, - { - path: "/:test", - options: { - trailing: false, - }, - tests: [ - { - input: "/route", - matches: ["/route", "route"], - expected: { path: "/route", index: 0, params: { test: "route" } }, - }, - { - input: "/route/", - matches: null, - expected: false, - }, - { - input: "/route/test", - matches: null, - expected: false, - }, - { - input: "/route/test/", - matches: null, - expected: false, - }, - { - input: "///route", - matches: ["///route", "route"], - expected: { path: "///route", index: 0, params: { test: "route" } }, - }, - ], - }, - { - path: "/:test/", - options: { - trailing: false, - }, - tests: [ - { - input: "/route", - matches: null, - expected: false, - }, - { - input: "/route/", - matches: ["/route/", "route"], - expected: { path: "/route/", index: 0, params: { test: "route" } }, - }, - { - input: "/route/test", - matches: null, - expected: false, - }, - { - input: "/route/test/", - matches: null, - expected: false, - }, - { - input: "/route//", - matches: ["/route//", "route"], - expected: { path: "/route//", index: 0, params: { test: "route" } }, + expected: { path: "/TEST", params: {} }, }, + { input: "/test", expected: false }, ], }, @@ -466,32 +330,26 @@ export const MATCH_TESTS: MatchTestSet[] = [ tests: [ { input: "/test", - matches: ["/test"], - expected: { path: "/test", index: 0, params: {} }, + expected: { path: "/test", params: {} }, }, { input: "/test/", - matches: ["/test/"], - expected: { path: "/test/", index: 0, params: {} }, + expected: { path: "/test/", params: {} }, }, { input: "/test////", - matches: ["/test////"], - expected: { path: "/test////", index: 0, params: {} }, + expected: { path: "/test", params: {} }, }, { input: "/route/test", - matches: null, expected: false, }, { input: "/test/route", - matches: ["/test"], - expected: { path: "/test", index: 0, params: {} }, + expected: { path: "/test", params: {} }, }, { input: "/route", - matches: null, expected: false, }, ], @@ -504,27 +362,22 @@ export const MATCH_TESTS: MatchTestSet[] = [ tests: [ { input: "/test", - matches: null, expected: false, }, { input: "/test/", - matches: ["/test/"], - expected: { path: "/test/", index: 0, params: {} }, + expected: { path: "/test/", params: {} }, }, { input: "/test//", - matches: ["/test//"], - expected: { path: "/test//", index: 0, params: {} }, + expected: { path: "/test//", params: {} }, }, { input: "/test/route", - matches: null, expected: false, }, { input: "/route/test/deep", - matches: null, expected: false, }, ], @@ -537,57 +390,41 @@ export const MATCH_TESTS: MatchTestSet[] = [ tests: [ { input: "/route", - matches: ["/route", "route"], - expected: { path: "/route", index: 0, params: { test: "route" } }, + expected: { path: "/route", params: { test: "route" } }, }, { input: "/route/", - matches: ["/route/", "route"], - expected: { path: "/route/", index: 0, params: { test: "route" } }, + expected: { path: "/route/", params: { test: "route" } }, }, { input: "/route.json", - matches: ["/route.json", "route.json"], expected: { path: "/route.json", - index: 0, params: { test: "route.json" }, }, }, { input: "/route.json/", - matches: ["/route.json/", "route.json"], expected: { path: "/route.json/", - index: 0, params: { test: "route.json" }, }, }, { input: "/route/test", - matches: ["/route", "route"], - expected: { path: "/route", index: 0, params: { test: "route" } }, + expected: { path: "/route", params: { test: "route" } }, }, { input: "/route.json/test", - matches: ["/route.json", "route.json"], expected: { path: "/route.json", - index: 0, params: { test: "route.json" }, }, }, - { - input: "///route///test", - matches: ["///route", "route"], - expected: { path: "///route", index: 0, params: { test: "route" } }, - }, { input: "/caf%C3%A9", - matches: ["/caf%C3%A9", "caf%C3%A9"], expected: { path: "/caf%C3%A9", - index: 0, params: { test: "café" }, }, }, @@ -601,28 +438,23 @@ export const MATCH_TESTS: MatchTestSet[] = [ tests: [ { input: "/route", - matches: null, expected: false, }, { input: "/route/", - matches: ["/route/", "route"], - expected: { path: "/route/", index: 0, params: { test: "route" } }, + expected: { path: "/route/", params: { test: "route" } }, }, { input: "/route/test", - matches: null, expected: false, }, { input: "/route/test/", - matches: null, expected: false, }, { input: "/route//test", - matches: null, - expected: false, + expected: { path: "/route/", params: { test: "route" } }, }, ], }, @@ -634,1028 +466,180 @@ export const MATCH_TESTS: MatchTestSet[] = [ tests: [ { input: "", - matches: [""], - expected: { path: "", index: 0, params: {} }, + expected: { path: "", params: {} }, }, { input: "/", - matches: ["/"], - expected: { path: "/", index: 0, params: {} }, + expected: { path: "/", params: {} }, }, { input: "route", - matches: null, expected: false, }, { input: "/route", - matches: [""], - expected: { path: "", index: 0, params: {} }, + expected: { path: "", params: {} }, }, { input: "/route/", - matches: [""], - expected: { path: "", index: 0, params: {} }, + expected: { path: "", params: {} }, }, ], }, /** - * Non-starting mode. + * Optional. */ { - path: "/test", - options: { - start: false, - }, + path: "{/route}", tests: [ { - input: "/test", - matches: ["/test"], - expected: { path: "/test", index: 0, params: {} }, - }, - { - input: "/test/", - matches: ["/test/"], - expected: { path: "/test/", index: 0, params: {} }, - }, - { - input: "/route/test", - matches: ["/test"], - expected: { path: "/test", index: 6, params: {} }, - }, - { - input: "/route/test/", - matches: ["/test/"], - expected: { path: "/test/", index: 6, params: {} }, + input: "", + expected: { path: "", params: {} }, }, { - input: "/test/route", - matches: null, - expected: false, + input: "/", + expected: { path: "/", params: {} }, }, { - input: "/route/test/deep", - matches: null, + input: "/foo", expected: false, }, { input: "/route", - matches: null, - expected: false, + expected: { path: "/route", params: {} }, }, ], }, { - path: "/test/", - options: { - start: false, - }, + path: "{/:test}", tests: [ { - input: "/test", - matches: null, - expected: false, - }, - { - input: "/test/", - matches: ["/test/"], - expected: { path: "/test/", index: 0, params: {} }, - }, - { - input: "/test//", - matches: ["/test//"], - expected: { path: "/test//", index: 0, params: {} }, + input: "/route", + expected: { path: "/route", params: { test: "route" } }, }, { - input: "/test/route", - matches: null, - expected: false, + input: "", + expected: { path: "", params: {} }, }, { - input: "/route/test", - matches: null, - expected: false, + input: "/", + expected: { path: "/", params: {} }, }, + ], + }, + { + path: "{/:test}/bar", + tests: [ { - input: "/route/test/", - matches: ["/test/"], - expected: { path: "/test/", index: 6, params: {} }, + input: "/bar", + expected: { path: "/bar", params: {} }, }, { - input: "/route/test//", - matches: ["/test//"], - expected: { path: "/test//", index: 6, params: {} }, + input: "/foo/bar", + expected: { path: "/foo/bar", params: { test: "foo" } }, }, { - input: "/route/test/deep", - matches: null, - expected: false, + input: "/foo/bar/", + expected: { path: "/foo/bar/", params: { test: "foo" } }, }, ], }, { - path: "/:test", - options: { - start: false, - }, + path: "{/:test}-bar", tests: [ { - input: "/route", - matches: ["/route", "route"], - expected: { path: "/route", index: 0, params: { test: "route" } }, - }, - { - input: "/route/", - matches: ["/route/", "route"], - expected: { path: "/route/", index: 0, params: { test: "route" } }, + input: "-bar", + expected: { path: "-bar", params: {} }, }, { - input: "/route/test", - matches: ["/test", "test"], - expected: { path: "/test", index: 6, params: { test: "test" } }, + input: "/foo-bar", + expected: { path: "/foo-bar", params: { test: "foo" } }, }, { - input: "/route/test/", - matches: ["/test/", "test"], - expected: { path: "/test/", index: 6, params: { test: "test" } }, + input: "/foo-bar/", + expected: { path: "/foo-bar/", params: { test: "foo" } }, }, ], }, { - path: "/:test/", - options: { - start: false, - }, + path: "/{:test}-bar", tests: [ { - input: "/route", - matches: null, - expected: false, + input: "/-bar", + expected: { path: "/-bar", params: {} }, }, { - input: "/route/", - matches: ["/route/", "route"], - expected: { path: "/route/", index: 0, params: { test: "route" } }, + input: "/foo-bar", + expected: { path: "/foo-bar", params: { test: "foo" } }, }, { - input: "/route/test", - matches: null, - expected: false, + input: "/foo-bar/", + expected: { path: "/foo-bar/", params: { test: "foo" } }, }, + ], + }, + + /** + * No prefix characters. + */ + { + path: "test", + tests: [ { - input: "/route/test/", - matches: ["/test/", "test"], - expected: { path: "/test/", index: 6, params: { test: "test" } }, + input: "test", + expected: { path: "test", params: {} }, }, { - input: "/route/test//", - matches: ["/test//", "test"], - expected: { path: "/test//", index: 6, params: { test: "test" } }, + input: "/test", + expected: false, }, ], }, { - path: "", - options: { - start: false, - }, + path: ":test", tests: [ { - input: "", - matches: [""], - expected: { path: "", index: 0, params: {} }, + input: "route", + expected: { path: "route", params: { test: "route" } }, }, { - input: "/", - matches: ["/"], - expected: { path: "/", index: 0, params: {} }, + input: "/route", + expected: false, }, { - input: "route", - matches: [""], - expected: { path: "", index: 5, params: {} }, + input: "route/", + expected: { path: "route/", params: { test: "route" } }, }, + ], + }, + { + path: "{:test}", + tests: [ { - input: "/route", - matches: [""], - expected: { path: "", index: 6, params: {} }, + input: "test", + expected: { path: "test", params: { test: "test" } }, }, { - input: "/route/", - matches: ["/"], - expected: { path: "/", index: 6, params: {} }, + input: "", + expected: { path: "", params: {} }, }, ], }, /** - * Non-ending and non-trailing modes. + * Formats. */ { - path: "/test", - options: { - end: false, - trailing: false, - }, + path: "/test.json", tests: [ { - input: "/test", - matches: ["/test"], - expected: { path: "/test", index: 0, params: {} }, + input: "/test.json", + expected: { path: "/test.json", params: {} }, }, { input: "/test", - matches: ["/test"], - expected: { path: "/test", index: 0, params: {} }, - }, - { - input: "/test/route", - matches: ["/test"], - expected: { path: "/test", index: 0, params: {} }, - }, - ], - }, - { - path: "/test/", - options: { - end: false, - trailing: false, - }, - tests: [ - { - input: "/test/", - matches: ["/test/"], - expected: { path: "/test/", index: 0, params: {} }, - }, - { - input: "/test", - matches: null, - expected: false, - }, - { - input: "/test//", - matches: ["/test//"], - expected: { path: "/test//", index: 0, params: {} }, - }, - { - input: "/test/route", - matches: null, - expected: false, - }, - { - input: "/route/test/deep", - matches: null, - expected: false, - }, - ], - }, - { - path: "/:test", - options: { - end: false, - trailing: false, - }, - tests: [ - { - input: "/route", - matches: ["/route", "route"], - expected: { path: "/route", index: 0, params: { test: "route" } }, - }, - { - input: "/route/", - matches: ["/route", "route"], - expected: { path: "/route", index: 0, params: { test: "route" } }, - }, - { - input: "/route/test", - matches: ["/route", "route"], - expected: { path: "/route", index: 0, params: { test: "route" } }, - }, - { - input: "/route/test/", - matches: ["/route", "route"], - expected: { path: "/route", index: 0, params: { test: "route" } }, - }, - ], - }, - { - path: "/:test/", - options: { - end: false, - trailing: false, - }, - tests: [ - { - input: "/route", - matches: null, - expected: false, - }, - { - input: "/route/", - matches: ["/route/", "route"], - expected: { path: "/route/", index: 0, params: { test: "route" } }, - }, - { - input: "/route/test", - matches: null, - expected: false, - }, - { - input: "/route/test/", - matches: null, - expected: false, - }, - { - input: "/route/test//", - matches: null, - expected: false, - }, - { - input: "/route//test", - matches: null, - expected: false, - }, - ], - }, - - /** - * Non-starting and non-ending modes. - */ - { - path: "/test", - options: { - start: false, - end: false, - }, - tests: [ - { - input: "/test", - matches: ["/test"], - expected: { path: "/test", index: 0, params: {} }, - }, - { - input: "/test/", - matches: ["/test/"], - expected: { path: "/test/", index: 0, params: {} }, - }, - { - input: "/test/route", - matches: ["/test"], - expected: { path: "/test", index: 0, params: {} }, - }, - { - input: "/route/test", - matches: ["/test"], - expected: { path: "/test", index: 6, params: {} }, - }, - ], - }, - { - path: "/test/", - options: { - start: false, - end: false, - }, - tests: [ - { - input: "/test/", - matches: ["/test/"], - expected: { path: "/test/", index: 0, params: {} }, - }, - { - input: "/test", - matches: null, - expected: false, - }, - { - input: "/test//", - matches: ["/test//"], - expected: { path: "/test//", index: 0, params: {} }, - }, - { - input: "/test/route", - matches: null, - expected: false, - }, - { - input: "/route/test/deep", - matches: null, - expected: false, - }, - { - input: "/route/test//deep", - matches: null, - expected: false, - }, - ], - }, - { - path: "/:test", - options: { - start: false, - end: false, - }, - tests: [ - { - input: "/route", - matches: ["/route", "route"], - expected: { path: "/route", index: 0, params: { test: "route" } }, - }, - { - input: "/route/", - matches: ["/route/", "route"], - expected: { path: "/route/", index: 0, params: { test: "route" } }, - }, - { - input: "/route/test", - matches: ["/route", "route"], - expected: { path: "/route", index: 0, params: { test: "route" } }, - }, - { - input: "/route/test/", - matches: ["/route", "route"], - expected: { path: "/route", index: 0, params: { test: "route" } }, - }, - ], - }, - { - path: "/:test/", - options: { - start: false, - end: false, - }, - tests: [ - { - input: "/route", - matches: null, - expected: false, - }, - { - input: "/route/", - matches: ["/route/", "route"], - expected: { path: "/route/", index: 0, params: { test: "route" } }, - }, - { - input: "/route/test", - matches: null, - expected: false, - }, - { - input: "/route/test/", - matches: ["/test/", "test"], - expected: { path: "/test/", index: 6, params: { test: "test" } }, - }, - { - input: "/route/test//", - matches: ["/test//", "test"], - expected: { path: "/test//", index: 6, params: { test: "test" } }, - }, - ], - }, - - /** - * Optional. - */ - { - path: "{/:test}?", - tests: [ - { - input: "/route", - matches: ["/route", "route"], - expected: { path: "/route", index: 0, params: { test: "route" } }, - }, - { - input: "///route", - matches: ["///route", "route"], - expected: { path: "///route", index: 0, params: { test: "route" } }, - }, - { - input: "///route///", - matches: ["///route///", "route"], - expected: { path: "///route///", index: 0, params: { test: "route" } }, - }, - { - input: "/", - matches: ["/", undefined], - expected: { path: "/", index: 0, params: {} }, - }, - { - input: "///", - matches: ["///", undefined], - expected: { path: "///", index: 0, params: {} }, - }, - ], - }, - { - path: "{/:test}?", - options: { - trailing: false, - }, - tests: [ - { - input: "/route", - matches: ["/route", "route"], - expected: { path: "/route", index: 0, params: { test: "route" } }, - }, - { - input: "/route/", - matches: null, - expected: false, - }, - { input: "/", matches: null, expected: false }, - { input: "///", matches: null, expected: false }, - ], - }, - { - path: "{/:test}?/bar", - tests: [ - { - input: "/bar", - matches: ["/bar", undefined], - expected: { path: "/bar", index: 0, params: {} }, - }, - { - input: "/foo/bar", - matches: ["/foo/bar", "foo"], - expected: { path: "/foo/bar", index: 0, params: { test: "foo" } }, - }, - { - input: "///foo///bar", - matches: ["///foo///bar", "foo"], - expected: { path: "///foo///bar", index: 0, params: { test: "foo" } }, - }, - { - input: "/foo/bar/", - matches: ["/foo/bar/", "foo"], - expected: { path: "/foo/bar/", index: 0, params: { test: "foo" } }, - }, - ], - }, - { - path: "{/:test}?-bar", - tests: [ - { - input: "-bar", - matches: ["-bar", undefined], - expected: { path: "-bar", index: 0, params: {} }, - }, - { - input: "/foo-bar", - matches: ["/foo-bar", "foo"], - expected: { path: "/foo-bar", index: 0, params: { test: "foo" } }, - }, - { - input: "/foo-bar/", - matches: ["/foo-bar/", "foo"], - expected: { path: "/foo-bar/", index: 0, params: { test: "foo" } }, - }, - ], - }, - { - path: "/{:test}?-bar", - tests: [ - { - input: "/-bar", - matches: ["/-bar", undefined], - expected: { path: "/-bar", index: 0, params: {} }, - }, - { - input: "/foo-bar", - matches: ["/foo-bar", "foo"], - expected: { path: "/foo-bar", index: 0, params: { test: "foo" } }, - }, - { - input: "/foo-bar/", - matches: ["/foo-bar/", "foo"], - expected: { path: "/foo-bar/", index: 0, params: { test: "foo" } }, - }, - ], - }, - - /** - * Zero or more times. - */ - { - path: "{/:test}*", - tests: [ - { - input: "/", - matches: ["/", undefined], - expected: { path: "/", index: 0, params: {} }, - }, - { - input: "//", - matches: ["//", undefined], - expected: { path: "//", index: 0, params: {} }, - }, - { - input: "/route", - matches: ["/route", "route"], - expected: { path: "/route", index: 0, params: { test: ["route"] } }, - }, - { - input: "/some/basic/route", - matches: ["/some/basic/route", "some/basic/route"], - expected: { - path: "/some/basic/route", - index: 0, - params: { test: ["some", "basic", "route"] }, - }, - }, - { - input: "///some///basic///route", - matches: ["///some///basic///route", "some///basic///route"], - expected: { - path: "///some///basic///route", - index: 0, - params: { test: ["some", "basic", "route"] }, - }, - }, - ], - }, - { - path: "{/:test}*-bar", - tests: [ - { - input: "-bar", - matches: ["-bar", undefined], - expected: { path: "-bar", index: 0, params: {} }, - }, - { - input: "/-bar", - matches: null, - expected: false, - }, - { - input: "/foo-bar", - matches: ["/foo-bar", "foo"], - expected: { path: "/foo-bar", index: 0, params: { test: ["foo"] } }, - }, - { - input: "/foo/baz-bar", - matches: ["/foo/baz-bar", "foo/baz"], - expected: { - path: "/foo/baz-bar", - index: 0, - params: { test: ["foo", "baz"] }, - }, - }, - ], - }, - - /** - * One or more times. - */ - { - path: "{/:test}+", - tests: [ - { - input: "/", - matches: null, - expected: false, - }, - { - input: "//", - matches: null, - expected: false, - }, - { - input: "/route", - matches: ["/route", "route"], - expected: { path: "/route", index: 0, params: { test: ["route"] } }, - }, - { - input: "/some/basic/route", - matches: ["/some/basic/route", "some/basic/route"], - expected: { - path: "/some/basic/route", - index: 0, - params: { test: ["some", "basic", "route"] }, - }, - }, - { - input: "///some///basic///route", - matches: ["///some///basic///route", "some///basic///route"], - expected: { - path: "///some///basic///route", - index: 0, - params: { test: ["some", "basic", "route"] }, - }, - }, - ], - }, - { - path: "{/:test}+-bar", - tests: [ - { - input: "-bar", - matches: null, - expected: false, - }, - { - input: "/-bar", - matches: null, - expected: false, - }, - { - input: "/foo-bar", - matches: ["/foo-bar", "foo"], - expected: { path: "/foo-bar", index: 0, params: { test: ["foo"] } }, - }, - { - input: "/foo/baz-bar", - matches: ["/foo/baz-bar", "foo/baz"], - expected: { - path: "/foo/baz-bar", - index: 0, - params: { test: ["foo", "baz"] }, - }, - }, - ], - }, - - /** - * Custom parameters. - */ - { - path: String.raw`/:test(\d+)`, - tests: [ - { - input: "/123", - matches: ["/123", "123"], - expected: { path: "/123", index: 0, params: { test: "123" } }, - }, - { - input: "/abc", - matches: null, - expected: false, - }, - { - input: "/123/abc", - matches: null, - expected: false, - }, - ], - }, - { - path: String.raw`/:test(\d+)-bar`, - tests: [ - { - input: "-bar", - matches: null, - expected: false, - }, - { - input: "/-bar", - matches: null, - expected: false, - }, - { - input: "/abc-bar", - matches: null, - expected: false, - }, - { - input: "/123-bar", - matches: ["/123-bar", "123"], - expected: { path: "/123-bar", index: 0, params: { test: "123" } }, - }, - { - input: "/123/456-bar", - matches: null, - expected: false, - }, - ], - }, - { - path: "/:test(.*)", - tests: [ - { - input: "/", - matches: ["/", ""], - expected: { path: "/", index: 0, params: { test: "" } }, - }, - { - input: "/route", - matches: ["/route", "route"], - expected: { path: "/route", index: 0, params: { test: "route" } }, - }, - { - input: "/route/123", - matches: ["/route/123", "route/123"], - expected: { - path: "/route/123", - index: 0, - params: { test: "route/123" }, - }, - }, - { - input: "/;,:@&=/+$-_.!/~*()", - matches: ["/;,:@&=/+$-_.!/~*()", ";,:@&=/+$-_.!/~*()"], - expected: { - path: "/;,:@&=/+$-_.!/~*()", - index: 0, - params: { test: ";,:@&=/+$-_.!/~*()" }, - }, - }, - ], - }, - { - path: "/:test([a-z]+)", - tests: [ - { - input: "/abc", - matches: ["/abc", "abc"], - expected: { path: "/abc", index: 0, params: { test: "abc" } }, - }, - { - input: "/123", - matches: null, - expected: false, - }, - { - input: "/abc/123", - matches: null, - expected: false, - }, - ], - }, - { - path: "/:test(this|that)", - tests: [ - { - input: "/this", - matches: ["/this", "this"], - expected: { path: "/this", index: 0, params: { test: "this" } }, - }, - { - input: "/that", - matches: ["/that", "that"], - expected: { path: "/that", index: 0, params: { test: "that" } }, - }, - { - input: "/foo", - matches: null, - expected: false, - }, - ], - }, - { - path: "{/:test(abc|xyz)}*", - tests: [ - { - input: "/", - matches: ["/", undefined], - expected: { path: "/", index: 0, params: { test: undefined } }, - }, - { - input: "/abc", - matches: ["/abc", "abc"], - expected: { path: "/abc", index: 0, params: { test: ["abc"] } }, - }, - { - input: "/abc/abc", - matches: ["/abc/abc", "abc/abc"], - expected: { - path: "/abc/abc", - index: 0, - params: { test: ["abc", "abc"] }, - }, - }, - { - input: "/xyz/xyz", - matches: ["/xyz/xyz", "xyz/xyz"], - expected: { - path: "/xyz/xyz", - index: 0, - params: { test: ["xyz", "xyz"] }, - }, - }, - { - input: "/abc/xyz", - matches: ["/abc/xyz", "abc/xyz"], - expected: { - path: "/abc/xyz", - index: 0, - params: { test: ["abc", "xyz"] }, - }, - }, - { - input: "/abc/xyz/abc/xyz", - matches: ["/abc/xyz/abc/xyz", "abc/xyz/abc/xyz"], - expected: { - path: "/abc/xyz/abc/xyz", - index: 0, - params: { test: ["abc", "xyz", "abc", "xyz"] }, - }, - }, - { - input: "/xyzxyz", - matches: null, - expected: false, - }, - ], - }, - - /** - * No prefix characters. - */ - { - path: "test", - tests: [ - { - input: "test", - matches: ["test"], - expected: { path: "test", index: 0, params: {} }, - }, - { - input: "/test", - matches: null, - expected: false, - }, - ], - }, - { - path: ":test", - tests: [ - { - input: "route", - matches: ["route", "route"], - expected: { path: "route", index: 0, params: { test: "route" } }, - }, - { - input: "/route", - matches: null, - expected: false, - }, - { - input: "route/", - matches: ["route/", "route"], - expected: { path: "route/", index: 0, params: { test: "route" } }, - }, - ], - }, - { - path: "{:test}?", - tests: [ - { - input: "test", - matches: ["test", "test"], - expected: { path: "test", index: 0, params: { test: "test" } }, - }, - { - input: "", - matches: ["", undefined], - expected: { path: "", index: 0, params: {} }, - }, - ], - }, - { - path: "{:test/}+", - tests: [ - { - input: "route/", - matches: ["route/", "route"], - expected: { path: "route/", index: 0, params: { test: ["route"] } }, - }, - { - input: "/route", - matches: null, - expected: false, - }, - { - input: "", - matches: null, - expected: false, - }, - { - input: "foo/bar/", - matches: ["foo/bar/", "foo/bar"], - expected: { - path: "foo/bar/", - index: 0, - params: { test: ["foo", "bar"] }, - }, - }, - ], - }, - - /** - * Formats. - */ - { - path: "/test.json", - tests: [ - { - input: "/test.json", - matches: ["/test.json"], - expected: { path: "/test.json", index: 0, params: {} }, - }, - { - input: "/test", - matches: null, - expected: false, + expected: false, }, ], }, @@ -1664,136 +648,19 @@ export const MATCH_TESTS: MatchTestSet[] = [ tests: [ { input: "/.json", - matches: null, expected: false, }, { input: "/test.json", - matches: ["/test.json", "test"], - expected: { path: "/test.json", index: 0, params: { test: "test" } }, + expected: { path: "/test.json", params: { test: "test" } }, }, { input: "/route.json", - matches: ["/route.json", "route"], - expected: { path: "/route.json", index: 0, params: { test: "route" } }, + expected: { path: "/route.json", params: { test: "route" } }, }, { input: "/route.json.json", - matches: ["/route.json.json", "route.json"], - expected: { - path: "/route.json.json", - index: 0, - params: { test: "route.json" }, - }, - }, - ], - }, - - /** - * Format params. - */ - { - path: "/test.:format(\\w+)", - tests: [ - { - input: "/test.html", - matches: ["/test.html", "html"], - expected: { path: "/test.html", index: 0, params: { format: "html" } }, - }, - { - input: "/test", - matches: null, - expected: false, - }, - ], - }, - { - path: "/test.:format(\\w+).:format(\\w+)", - tests: [ - { - input: "/test.html.json", - matches: ["/test.html.json", "html", "json"], - expected: { - path: "/test.html.json", - index: 0, - params: { format: "json" }, - }, - }, - { - input: "/test.html", - matches: null, - expected: false, - }, - ], - }, - { - path: "/test{.:format(\\w+)}?", - tests: [ - { - input: "/test", - matches: ["/test", undefined], - expected: { path: "/test", index: 0, params: { format: undefined } }, - }, - { - input: "/test.html", - matches: ["/test.html", "html"], - expected: { path: "/test.html", index: 0, params: { format: "html" } }, - }, - ], - }, - { - path: "/test{.:format(\\w+)}+", - tests: [ - { - input: "/test", - matches: null, - expected: false, - }, - { - input: "/test.html", - matches: ["/test.html", "html"], - expected: { - path: "/test.html", - index: 0, - params: { format: ["html"] }, - }, - }, - { - input: "/test.html.json", - matches: ["/test.html.json", "html.json"], - expected: { - path: "/test.html.json", - index: 0, - params: { format: ["html", "json"] }, - }, - }, - ], - }, - { - path: "/test{.:format}+", - tests: [ - { - input: "/test", - matches: null, - expected: false, - }, - { - input: "/test.html", - matches: ["/test.html", "html"], - expected: { - path: "/test.html", - index: 0, - params: { format: ["html"] }, - }, - }, - { - input: "/test.hbs.html", - matches: ["/test.hbs.html", "hbs.html"], - expected: { - path: "/test.hbs.html", - index: 0, - params: { format: ["hbs", "html"] }, - }, + expected: { path: "/route.json.json", params: { test: "route.json" } }, }, ], }, @@ -1806,53 +673,43 @@ export const MATCH_TESTS: MatchTestSet[] = [ tests: [ { input: "/route.html", - matches: ["/route.html", "route", "html"], expected: { path: "/route.html", - index: 0, params: { test: "route", format: "html" }, }, }, { input: "/route", - matches: null, expected: false, }, { input: "/route.html.json", - matches: ["/route.html.json", "route", "html.json"], expected: { path: "/route.html.json", - index: 0, - params: { test: "route", format: "html.json" }, + params: { test: "route.html", format: "json" }, }, }, ], }, { - path: "/:test{.:format}?", + path: "/:test{.:format}", tests: [ { input: "/route", - matches: ["/route", "route", undefined], - expected: { path: "/route", index: 0, params: { test: "route" } }, + expected: { path: "/route", params: { test: "route" } }, }, { input: "/route.json", - matches: ["/route.json", "route", "json"], expected: { path: "/route.json", - index: 0, params: { test: "route", format: "json" }, }, }, { input: "/route.json.html", - matches: ["/route.json.html", "route", "json.html"], expected: { path: "/route.json.html", - index: 0, - params: { test: "route", format: "json.html" }, + params: { test: "route.json", format: "html" }, }, }, ], @@ -1862,116 +719,15 @@ export const MATCH_TESTS: MatchTestSet[] = [ tests: [ { input: "/route.htmlz", - matches: ["/route.htmlz", "route", "html"], expected: { path: "/route.htmlz", - index: 0, params: { test: "route", format: "html" }, }, }, { input: "/route.html", - matches: null, - expected: false, - }, - ], - }, - - /** - * Unnamed params. - */ - { - path: "/(\\d+)", - tests: [ - { - input: "/123", - matches: ["/123", "123"], - expected: { path: "/123", index: 0, params: { "0": "123" } }, - }, - { - input: "/abc", - matches: null, - expected: false, - }, - { - input: "/123/abc", - matches: null, - expected: false, - }, - ], - }, - { - path: "{/(\\d+)}?", - tests: [ - { - input: "/", - matches: ["/", undefined], - expected: { path: "/", index: 0, params: { "0": undefined } }, - }, - { - input: "/123", - matches: ["/123", "123"], - expected: { path: "/123", index: 0, params: { "0": "123" } }, - }, - ], - }, - { - path: "/route\\(\\\\(\\d+\\\\)\\)", - tests: [ - { - input: "/route(\\123\\)", - matches: ["/route(\\123\\)", "123\\"], - expected: { - path: "/route(\\123\\)", - index: 0, - params: { "0": "123\\" }, - }, - }, - { - input: "/route(\\123)", - matches: null, - expected: false, - }, - ], - }, - { - path: "{/route}?", - tests: [ - { - input: "", - matches: [""], - expected: { path: "", index: 0, params: {} }, - }, - { - input: "/", - matches: ["/"], - expected: { path: "/", index: 0, params: {} }, - }, - { - input: "/foo", - matches: null, expected: false, }, - { - input: "/route", - matches: ["/route"], - expected: { path: "/route", index: 0, params: {} }, - }, - ], - }, - { - path: "{/(.*)}", - tests: [ - { - input: "/", - matches: ["/", ""], - expected: { path: "/", index: 0, params: { "0": "" } }, - }, - { - input: "/login", - matches: ["/login", "login"], - expected: { path: "/login", index: 0, params: { "0": "login" } }, - }, ], }, @@ -1983,61 +739,20 @@ export const MATCH_TESTS: MatchTestSet[] = [ tests: [ { input: "/testing", - matches: null, expected: false, }, { input: "/(testing)", - matches: ["/(testing)"], - expected: { path: "/(testing)", index: 0, params: {} }, + expected: { path: "/(testing)", params: {} }, }, ], }, { - path: "/.\\+\\*\\?\\{\\}=^\\!\\:$[]\\|", + path: "/.\\+\\*\\?\\{\\}=^\\!\\:$\\[\\]\\|", tests: [ { input: "/.+*?{}=^!:$[]|", - matches: ["/.+*?{}=^!:$[]|"], - expected: { path: "/.+*?{}=^!:$[]|", index: 0, params: {} }, - }, - ], - }, - { - path: "/test/{:uid(u\\d+)}?{:cid(c\\d+)}?", - tests: [ - { - input: "/test/u123", - matches: ["/test/u123", "u123", undefined], - expected: { path: "/test/u123", index: 0, params: { uid: "u123" } }, - }, - { - input: "/test/c123", - matches: ["/test/c123", undefined, "c123"], - expected: { path: "/test/c123", index: 0, params: { cid: "c123" } }, - }, - ], - }, - - /** - * Unnamed group prefix. - */ - { - path: "/{apple-}?icon-:res(\\d+).png", - tests: [ - { - input: "/icon-240.png", - matches: ["/icon-240.png", "240"], - expected: { path: "/icon-240.png", index: 0, params: { res: "240" } }, - }, - { - input: "/apple-icon-240.png", - matches: ["/apple-icon-240.png", "240"], - expected: { - path: "/apple-icon-240.png", - index: 0, - params: { res: "240" }, - }, + expected: { path: "/.+*?{}=^!:$[]|", params: {} }, }, ], }, @@ -2049,114 +764,50 @@ export const MATCH_TESTS: MatchTestSet[] = [ path: "/:foo/:bar", tests: [ { - input: "/match/route", - matches: ["/match/route", "match", "route"], - expected: { - path: "/match/route", - index: 0, - params: { foo: "match", bar: "route" }, - }, - }, - ], - }, - { - path: "/:foo\\(test\\)/bar", - tests: [ - { - input: "/foo(test)/bar", - matches: ["/foo(test)/bar", "foo"], - expected: { path: "/foo(test)/bar", index: 0, params: { foo: "foo" } }, - }, - { - input: "/foo/bar", - matches: null, - expected: false, - }, - ], - }, - { - path: "/:remote([\\w\\-\\.]+)/:user([\\w-]+)", - tests: [ - { - input: "/endpoint/user", - matches: ["/endpoint/user", "endpoint", "user"], - expected: { - path: "/endpoint/user", - index: 0, - params: { remote: "endpoint", user: "user" }, - }, - }, - { - input: "/endpoint/user-name", - matches: ["/endpoint/user-name", "endpoint", "user-name"], - expected: { - path: "/endpoint/user-name", - index: 0, - params: { remote: "endpoint", user: "user-name" }, - }, - }, - { - input: "/foo.bar/user-name", - matches: ["/foo.bar/user-name", "foo.bar", "user-name"], + input: "/match/route", expected: { - path: "/foo.bar/user-name", - index: 0, - params: { remote: "foo.bar", user: "user-name" }, + path: "/match/route", + params: { foo: "match", bar: "route" }, }, }, ], }, { - path: "/:foo\\?", + path: "/:foo\\(test\\)/bar", tests: [ { - input: "/route?", - matches: ["/route?", "route"], - expected: { path: "/route?", index: 0, params: { foo: "route" } }, + input: "/foo(test)/bar", + expected: { path: "/foo(test)/bar", params: { foo: "foo" } }, }, { - input: "/route", - matches: null, + input: "/foo/bar", expected: false, }, ], }, { - path: "{/:foo}+bar", + path: "/:foo\\?", tests: [ { - input: "/foobar", - matches: ["/foobar", "foo"], - expected: { path: "/foobar", index: 0, params: { foo: ["foo"] } }, + input: "/route?", + expected: { path: "/route?", params: { foo: "route" } }, }, { - input: "/foo/bar", - matches: null, + input: "/route", expected: false, }, - { - input: "/foo/barbar", - matches: ["/foo/barbar", "foo/bar"], - expected: { - path: "/foo/barbar", - index: 0, - params: { foo: ["foo", "bar"] }, - }, - }, ], }, { - path: "/{:pre}?baz", + path: "/{:pre}baz", tests: [ { input: "/foobaz", - matches: ["/foobaz", "foo"], - expected: { path: "/foobaz", index: 0, params: { pre: "foo" } }, + expected: { path: "/foobaz", params: { pre: "foo" } }, }, { input: "/baz", - matches: ["/baz", undefined], - expected: { path: "/baz", index: 0, params: { pre: undefined } }, + expected: { path: "/baz", params: { pre: undefined } }, }, ], }, @@ -2165,125 +816,83 @@ export const MATCH_TESTS: MatchTestSet[] = [ tests: [ { input: "/hello(world)", - matches: ["/hello(world)", "hello", "world"], expected: { path: "/hello(world)", - index: 0, params: { foo: "hello", bar: "world" }, }, }, { input: "/hello()", - matches: null, expected: false, }, ], }, { - path: "/:foo\\({:bar}?\\)", + path: "/:foo\\({:bar}\\)", tests: [ { input: "/hello(world)", - matches: ["/hello(world)", "hello", "world"], expected: { path: "/hello(world)", - index: 0, params: { foo: "hello", bar: "world" }, }, }, { input: "/hello()", - matches: ["/hello()", "hello", undefined], expected: { path: "/hello()", - index: 0, params: { foo: "hello", bar: undefined }, }, }, ], }, { - path: "/:postType(video|audio|text){(\\+.+)}?", - tests: [ - { - input: "/video", - matches: ["/video", "video", undefined], - expected: { path: "/video", index: 0, params: { postType: "video" } }, - }, - { - input: "/video+test", - matches: ["/video+test", "video", "+test"], - expected: { - path: "/video+test", - index: 0, - params: { 0: "+test", postType: "video" }, - }, - }, - { - input: "/video+", - matches: null, - expected: false, - }, - ], - }, - { - path: "{/:foo}?{/:bar}?-ext", + path: "{/:foo}{/:bar}-ext", tests: [ { input: "/-ext", - matches: null, expected: false, }, { input: "-ext", - matches: ["-ext", undefined, undefined], expected: { path: "-ext", - index: 0, params: { foo: undefined, bar: undefined }, }, }, { input: "/foo-ext", - matches: ["/foo-ext", "foo", undefined], - expected: { path: "/foo-ext", index: 0, params: { foo: "foo" } }, + expected: { path: "/foo-ext", params: { foo: "foo" } }, }, { input: "/foo/bar-ext", - matches: ["/foo/bar-ext", "foo", "bar"], expected: { path: "/foo/bar-ext", - index: 0, params: { foo: "foo", bar: "bar" }, }, }, { input: "/foo/-ext", - matches: null, expected: false, }, ], }, { - path: "/:required{/:optional}?-ext", + path: "/:required{/:optional}-ext", tests: [ { input: "/foo-ext", - matches: ["/foo-ext", "foo", undefined], - expected: { path: "/foo-ext", index: 0, params: { required: "foo" } }, + expected: { path: "/foo-ext", params: { required: "foo" } }, }, { input: "/foo/bar-ext", - matches: ["/foo/bar-ext", "foo", "bar"], expected: { path: "/foo/bar-ext", - index: 0, params: { required: "foo", optional: "bar" }, }, }, { input: "/foo/-ext", - matches: null, expected: false, }, ], @@ -2297,8 +906,7 @@ export const MATCH_TESTS: MatchTestSet[] = [ tests: [ { input: "/café", - matches: ["/café", "café"], - expected: { path: "/café", index: 0, params: { foo: "café" } }, + expected: { path: "/café", params: { foo: "café" } }, }, ], }, @@ -2310,10 +918,8 @@ export const MATCH_TESTS: MatchTestSet[] = [ tests: [ { input: "/caf%C3%A9", - matches: ["/caf%C3%A9", "caf%C3%A9"], expected: { path: "/caf%C3%A9", - index: 0, params: { foo: "caf%C3%A9" }, }, }, @@ -2324,8 +930,7 @@ export const MATCH_TESTS: MatchTestSet[] = [ tests: [ { input: "/café", - matches: ["/café"], - expected: { path: "/café", index: 0, params: {} }, + expected: { path: "/café", params: {} }, }, ], }, @@ -2337,8 +942,7 @@ export const MATCH_TESTS: MatchTestSet[] = [ tests: [ { input: "/caf%C3%A9", - matches: ["/caf%C3%A9"], - expected: { path: "/caf%C3%A9", index: 0, params: {} }, + expected: { path: "/caf%C3%A9", params: {} }, }, ], }, @@ -2354,19 +958,15 @@ export const MATCH_TESTS: MatchTestSet[] = [ tests: [ { input: "example.com", - matches: ["example.com", "example"], expected: { path: "example.com", - index: 0, params: { domain: "example" }, }, }, { input: "github.com", - matches: ["github.com", "github"], expected: { path: "github.com", - index: 0, params: { domain: "github" }, }, }, @@ -2380,50 +980,41 @@ export const MATCH_TESTS: MatchTestSet[] = [ tests: [ { input: "mail.example.com", - matches: ["mail.example.com", "example"], expected: { path: "mail.example.com", - index: 0, params: { domain: "example" }, }, }, { input: "mail.github.com", - matches: ["mail.github.com", "github"], expected: { path: "mail.github.com", - index: 0, params: { domain: "github" }, }, }, ], }, { - path: "mail{.:domain}?.com", + path: "mail{.:domain}.com", options: { delimiter: ".", }, tests: [ { input: "mail.com", - matches: ["mail.com", undefined], - expected: { path: "mail.com", index: 0, params: { domain: undefined } }, + expected: { path: "mail.com", params: { domain: undefined } }, }, { input: "mail.example.com", - matches: ["mail.example.com", "example"], expected: { path: "mail.example.com", - index: 0, params: { domain: "example" }, }, }, { input: "mail.github.com", - matches: ["mail.github.com", "github"], expected: { path: "mail.github.com", - index: 0, params: { domain: "github" }, }, }, @@ -2437,13 +1028,11 @@ export const MATCH_TESTS: MatchTestSet[] = [ tests: [ { input: "example.com", - matches: ["example.com", "com"], - expected: { path: "example.com", index: 0, params: { ext: "com" } }, + expected: { path: "example.com", params: { ext: "com" } }, }, { input: "example.org", - matches: ["example.org", "org"], - expected: { path: "example.org", index: 0, params: { ext: "org" } }, + expected: { path: "example.org", params: { ext: "org" } }, }, ], }, @@ -2456,12 +1045,10 @@ export const MATCH_TESTS: MatchTestSet[] = [ tests: [ { input: "this is a test", - matches: ["this is"], - expected: { path: "this is", index: 0, params: {} }, + expected: { path: "this is", params: {} }, }, { input: "this isn't", - matches: null, expected: false, }, ], @@ -2471,428 +1058,233 @@ export const MATCH_TESTS: MatchTestSet[] = [ * Prefixes. */ { - path: "{$:foo}{$:bar}?", - tests: [ - { - input: "$x", - matches: ["$x", "x", undefined], - expected: { path: "$x", index: 0, params: { foo: "x" } }, - }, - { - input: "$x$y", - matches: ["$x$y", "x", "y"], - expected: { path: "$x$y", index: 0, params: { foo: "x", bar: "y" } }, - }, - ], - }, - { - path: "{$:foo}+", + path: "$:foo{$:bar}", tests: [ { input: "$x", - matches: ["$x", "x"], - expected: { path: "$x", index: 0, params: { foo: ["x"] } }, + expected: { path: "$x", params: { foo: "x" } }, }, { input: "$x$y", - matches: ["$x$y", "x$y"], - expected: { path: "$x$y", index: 0, params: { foo: ["x", "y"] } }, + expected: { path: "$x$y", params: { foo: "x", bar: "y" } }, }, ], }, { - path: "name{/:attr1}?{-:attr2}?{-:attr3}?", + path: "name{/:attr1}{-:attr2}{-:attr3}", tests: [ { input: "name", - matches: ["name", undefined, undefined, undefined], - expected: { path: "name", index: 0, params: {} }, + expected: { path: "name", params: {} }, }, { input: "name/test", - matches: ["name/test", "test", undefined, undefined], expected: { path: "name/test", - index: 0, params: { attr1: "test" }, }, }, { input: "name/1", - matches: ["name/1", "1", undefined, undefined], expected: { path: "name/1", - index: 0, params: { attr1: "1" }, }, }, { input: "name/1-2", - matches: ["name/1-2", "1", "2", undefined], expected: { path: "name/1-2", - index: 0, params: { attr1: "1", attr2: "2" }, }, }, { input: "name/1-2-3", - matches: ["name/1-2-3", "1", "2", "3"], expected: { path: "name/1-2-3", - index: 0, params: { attr1: "1", attr2: "2", attr3: "3" }, }, }, { input: "name/foo-bar/route", - matches: null, expected: false, }, { input: "name/test/route", - matches: null, expected: false, }, ], }, + + /** + * https://github.com/pillarjs/path-to-regexp/issues/206 + */ { - path: "name{/:attrs;-}*", + path: "/user{s}/:user", tests: [ { - input: "name", - matches: ["name", undefined], - expected: { path: "name", index: 0, params: {} }, - }, - { - input: "name/1", - matches: ["name/1", "1"], - expected: { - path: "name/1", - index: 0, - params: { attrs: ["1"] }, - }, - }, - { - input: "name/1-2", - matches: ["name/1-2", "1-2"], - expected: { - path: "name/1-2", - index: 0, - params: { attrs: ["1", "2"] }, - }, - }, - { - input: "name/1-2-3", - matches: ["name/1-2-3", "1-2-3"], - expected: { - path: "name/1-2-3", - index: 0, - params: { attrs: ["1", "2", "3"] }, - }, - }, - { - input: "name/foo-bar/route", - matches: null, - expected: false, + input: "/user/123", + expected: { path: "/user/123", params: { user: "123" } }, }, { - input: "name/test/route", - matches: null, - expected: false, + input: "/users/123", + expected: { path: "/users/123", params: { user: "123" } }, }, ], }, /** - * Nested parentheses. + * Wildcard. */ { - path: "/:test(\\d+(?:\\.\\d+)?)", + path: "/*path", tests: [ { - input: "/123", - matches: ["/123", "123"], - expected: { path: "/123", index: 0, params: { test: "123" } }, - }, - { - input: "/abc", - matches: null, - expected: false, - }, - { - input: "/123/abc", - matches: null, - expected: false, - }, - { - input: "/123.123", - matches: ["/123.123", "123.123"], - expected: { path: "/123.123", index: 0, params: { test: "123.123" } }, - }, - { - input: "/123.abc", - matches: null, + input: "/", expected: false, }, - ], - }, - { - path: "/:test((?!login)[^/]+)", - tests: [ { input: "/route", - matches: ["/route", "route"], - expected: { path: "/route", index: 0, params: { test: "route" } }, + expected: { path: "/route", params: { path: ["route"] } }, }, { - input: "/login", - matches: null, - expected: false, + input: "/route/nested", + expected: { + path: "/route/nested", + params: { path: ["route", "nested"] }, + }, }, ], }, - - /** - * https://github.com/pillarjs/path-to-regexp/issues/206 - */ { - path: "/user{(s)}?/:user", + path: "*path", tests: [ { - input: "/user/123", - matches: ["/user/123", undefined, "123"], - expected: { path: "/user/123", index: 0, params: { user: "123" } }, + input: "/", + expected: { path: "/", params: { path: ["", ""] } }, }, { - input: "/users/123", - matches: ["/users/123", "s", "123"], - expected: { - path: "/users/123", - index: 0, - params: { 0: "s", user: "123" }, - }, + input: "/test", + expected: { path: "/test", params: { path: ["", "test"] } }, }, ], }, { - path: "/user{s}?/:user", + path: "*path", + options: { decode: false }, tests: [ { - input: "/user/123", - matches: ["/user/123", "123"], - expected: { path: "/user/123", index: 0, params: { user: "123" } }, + input: "/", + expected: { path: "/", params: { path: "/" } }, }, { - input: "/users/123", - matches: ["/users/123", "123"], - expected: { path: "/users/123", index: 0, params: { user: "123" } }, + input: "/test", + expected: { path: "/test", params: { path: "/test" } }, }, ], }, - - /** - * https://github.com/pillarjs/path-to-regexp/pull/270 - */ { - path: "/files{/:path}*{.:ext}*", + path: "/*path.:ext", tests: [ { - input: "/files/hello/world.txt", - matches: ["/files/hello/world.txt", "hello/world", "txt"], + input: "/test.html", expected: { - path: "/files/hello/world.txt", - index: 0, - params: { path: ["hello", "world"], ext: ["txt"] }, + path: "/test.html", + params: { path: ["test"], ext: "html" }, }, }, { - input: "/files/hello/world.txt.png", - matches: ["/files/hello/world.txt.png", "hello/world", "txt.png"], - expected: { - path: "/files/hello/world.txt.png", - index: 0, - params: { path: ["hello", "world"], ext: ["txt", "png"] }, - }, + input: "/test.html/nested", + expected: false, }, { - input: "/files/my/photo.jpg/gif", - matches: ["/files/my/photo.jpg/gif", "my/photo.jpg/gif", undefined], + input: "/test.html/nested.json", expected: { - path: "/files/my/photo.jpg/gif", - index: 0, - params: { path: ["my", "photo.jpg", "gif"], ext: undefined }, + path: "/test.html/nested.json", + params: { path: ["test.html", "nested"], ext: "json" }, }, }, ], }, { - path: "/files{/:path}*{.:ext}?", + path: "/:path.*ext", tests: [ { - input: "/files/hello/world.txt", - matches: ["/files/hello/world.txt", "hello/world", "txt"], + input: "/test.html", expected: { - path: "/files/hello/world.txt", - index: 0, - params: { path: ["hello", "world"], ext: "txt" }, + path: "/test.html", + params: { path: "test", ext: ["html"] }, }, }, { - input: "/files/my/photo.jpg/gif", - matches: ["/files/my/photo.jpg/gif", "my/photo.jpg/gif", undefined], + input: "/test.html/nested", expected: { - path: "/files/my/photo.jpg/gif", - index: 0, - params: { path: ["my", "photo.jpg", "gif"], ext: undefined }, + path: "/test.html/nested", + params: { path: "test", ext: ["html", "nested"] }, }, }, - ], - }, - { - path: "#/*", - tests: [ - { - input: "#/", - matches: ["#/", undefined], - expected: { path: "#/", index: 0, params: {} }, - }, - ], - }, - { - path: "/foo{/:bar}*", - tests: [ { - input: "/foo/test1//test2", - matches: ["/foo/test1//test2", "test1//test2"], + input: "/test.html/nested.json", expected: { - path: "/foo/test1//test2", - index: 0, - params: { bar: ["test1", "test2"] }, + path: "/test.html/nested.json", + params: { path: "test", ext: ["html", "nested.json"] }, }, }, ], }, { - path: "/entity/:id/*", + path: "/*path{.:ext}", tests: [ { - input: "/entity/foo", - matches: null, - expected: false, + input: "/test.html", + expected: { + path: "/test.html", + params: { path: ["test"], ext: "html" }, + }, }, { - input: "/entity/foo/", - matches: ["/entity/foo/", "foo", undefined], - expected: { path: "/entity/foo/", index: 0, params: { id: "foo" } }, + input: "/test.html/nested", + expected: { + params: { + path: ["test.html", "nested"], + }, + path: "/test.html/nested", + }, }, ], }, { - path: "/test/*", + path: "/entity/:id/*path", tests: [ { - input: "/test", - matches: null, + input: "/entity/foo", expected: false, }, { - input: "/test/", - matches: ["/test/", undefined], - expected: { path: "/test/", index: 0, params: {} }, - }, - { - input: "/test/route", - matches: ["/test/route", "route"], - expected: { path: "/test/route", index: 0, params: { "0": ["route"] } }, - }, - { - input: "/test/route/nested", - matches: ["/test/route/nested", "route/nested"], + input: "/entity/foo/path", expected: { - path: "/test/route/nested", - index: 0, - params: { "0": ["route", "nested"] }, + path: "/entity/foo/path", + params: { id: "foo", path: ["path"] }, }, }, ], }, - - /** - * Asterisk wildcard. - */ { - path: "/*", + path: "/*foo/:bar/*baz", tests: [ { - input: "/", - matches: ["/", undefined], - expected: { path: "/", index: 0, params: { "0": undefined } }, - }, - { - input: "/route", - matches: ["/route", "route"], - expected: { path: "/route", index: 0, params: { "0": ["route"] } }, - }, - { - input: "/route/nested", - matches: ["/route/nested", "route/nested"], + input: "/x/y/z", expected: { - path: "/route/nested", - index: 0, - params: { "0": ["route", "nested"] }, + path: "/x/y/z", + params: { foo: ["x"], bar: "y", baz: ["z"] }, }, }, - ], - }, - { - path: "*", - tests: [ - { - input: "/", - matches: ["/", "/"], - expected: { path: "/", index: 0, params: { "0": ["", ""] } }, - }, - { - input: "/test", - matches: ["/test", "/test"], - expected: { path: "/test", index: 0, params: { "0": ["", "test"] } }, - }, - ], - }, - { - path: "*", - options: { decode: false }, - tests: [ - { - input: "/", - matches: ["/", "/"], - expected: { path: "/", index: 0, params: { "0": "/" } }, - }, - { - input: "/test", - matches: ["/test", "/test"], - expected: { path: "/test", index: 0, params: { "0": "/test" } }, - }, - ], - }, - - /** - * No loose. - */ - { - path: "/test", - options: { loose: false }, - tests: [ - { - input: "/test", - matches: ["/test"], - expected: { path: "/test", index: 0, params: {} }, - }, { - input: "//test", - matches: null, - expected: false, + input: "/1/2/3/4/5", + expected: { + path: "/1/2/3/4/5", + params: { foo: ["1", "2", "3"], bar: "4", baz: ["5"] }, + }, }, ], }, @@ -2901,147 +1293,116 @@ export const MATCH_TESTS: MatchTestSet[] = [ * Longer prefix. */ { - path: "/:foo{/test/:bar}?", + path: "/:foo{/test/:bar}", tests: [ { input: "/route", - matches: ["/route", "route", undefined], - expected: { path: "/route", index: 0, params: { foo: "route" } }, + expected: { path: "/route", params: { foo: "route" } }, }, { input: "/route/test/again", - matches: ["/route/test/again", "route", "again"], expected: { path: "/route/test/again", - index: 0, params: { foo: "route", bar: "again" }, }, }, ], }, - /** - * Prefix and suffix as separator. - */ - { - path: "/{<:foo>}+", - tests: [ - { - input: "/", - matches: ["/", "test"], - expected: { path: "/", index: 0, params: { foo: ["test"] } }, - }, - { - input: "/", - matches: ["/", "test>", - index: 0, - params: { foo: ["test", "again"] }, - }, - }, - ], - }, - /** * Backtracking tests. */ { - path: "{:foo/}?{:bar.}?", + path: "{:foo/}{:bar.}", tests: [ { input: "", - matches: ["", undefined, undefined], - expected: { path: "", index: 0, params: {} }, + expected: { path: "", params: {} }, }, { input: "test/", - matches: ["test/", "test", undefined], expected: { path: "test/", - index: 0, params: { foo: "test" }, }, }, { input: "a/b.", - matches: ["a/b.", "a", "b"], - expected: { path: "a/b.", index: 0, params: { foo: "a", bar: "b" } }, + expected: { path: "a/b.", params: { foo: "a", bar: "b" } }, }, ], }, { - path: "/abc{abc:foo}?", + path: "/abc{abc:foo}", tests: [ { input: "/abc", - matches: ["/abc", undefined], - expected: { path: "/abc", index: 0, params: {} }, + expected: { path: "/abc", params: {} }, }, { input: "/abcabc", - matches: null, expected: false, }, { input: "/abcabc123", - matches: ["/abcabc123", "123"], - expected: { path: "/abcabc123", index: 0, params: { foo: "123" } }, + expected: { path: "/abcabc123", params: { foo: "123" } }, }, { input: "/abcabcabc123", - matches: ["/abcabcabc123", "abc123"], expected: { path: "/abcabcabc123", - index: 0, params: { foo: "abc123" }, }, }, { input: "/abcabcabc", - matches: ["/abcabcabc", "abc"], - expected: { path: "/abcabcabc", index: 0, params: { foo: "abc" } }, + expected: { path: "/abcabcabc", params: { foo: "abc" } }, }, ], }, { - path: "/:foo{abc:bar}?", + path: "/:foo{abc:bar}", tests: [ { input: "/abc", - matches: ["/abc", "abc", undefined], - expected: { path: "/abc", index: 0, params: { foo: "abc" } }, + expected: { + params: { foo: "abc" }, + path: "/abc", + }, }, { input: "/abcabc", - matches: ["/abcabc", "abcabc", undefined], - expected: { path: "/abcabc", index: 0, params: { foo: "abcabc" } }, + expected: { + params: { foo: "abcabc" }, + path: "/abcabc", + }, }, { input: "/abcabc123", - matches: ["/abcabc123", "abc", "123"], expected: { - path: "/abcabc123", - index: 0, params: { foo: "abc", bar: "123" }, + path: "/abcabc123", }, }, { - input: "/abcabcabc123", - matches: ["/abcabcabc123", "abc", "abc123"], + input: "/acb", expected: { - path: "/abcabcabc123", - index: 0, - params: { foo: "abc", bar: "abc123" }, + path: "/acb", + params: { foo: "acb" }, }, }, { - input: "/abcabcabc", - matches: ["/abcabcabc", "abc", "abc"], + input: "/123", + expected: { + path: "/123", + params: { foo: "123" }, + }, + }, + { + input: "/123abcabc", expected: { - path: "/abcabcabc", - index: 0, - params: { foo: "abc", bar: "abc" }, + path: "/123abcabc", + params: { foo: "123abcabc" }, }, }, ], @@ -3051,93 +1412,70 @@ export const MATCH_TESTS: MatchTestSet[] = [ tests: [ { input: "/abc", - matches: null, expected: false, }, { input: "/abcabc", - matches: null, expected: false, }, { input: "/abcabc123", - matches: ["/abcabc123", "abc", "123"], expected: { path: "/abcabc123", - index: 0, params: { foo: "abc", bar: "123" }, }, }, { - input: "/abcabcabc123", - matches: ["/abcabcabc123", "abc", "abc123"], - expected: { - path: "/abcabcabc123", - index: 0, - params: { foo: "abc", bar: "abc123" }, - }, - }, - { - input: "/abcabcabc", - matches: ["/abcabcabc", "abc", "abc"], - expected: { - path: "/abcabcabc", - index: 0, - params: { foo: "abc", bar: "abc" }, - }, + input: "/123abcabc", + expected: false, }, ], }, { - path: "/:foo(.*){.:ext}?", + path: "/route|:param|", tests: [ { - input: "/abc", - matches: ["/abc", "abc", undefined], - expected: { path: "/abc", index: 0, params: { foo: "abc" } }, + input: "/route|world|", + expected: { + path: "/route|world|", + params: { param: "world" }, + }, }, { - input: "/abc.txt", - matches: ["/abc.txt", "abc.txt", undefined], - expected: { path: "/abc.txt", index: 0, params: { foo: "abc.txt" } }, + input: "/route||", + expected: false, }, ], }, { - path: "/route|:param|", + path: "/:foo|:bar|", tests: [ { - input: "/route|world|", - matches: ["/route|world|", "world"], + input: "/hello|world|", expected: { - path: "/route|world|", - index: 0, - params: { param: "world" }, + path: "/hello|world|", + params: { foo: "hello", bar: "world" }, }, }, { - input: "/route||", - matches: null, + input: "/hello||", expected: false, }, ], }, { - path: "/:foo|:bar|", + path: "/:foo{|:bar|}", tests: [ { input: "/hello|world|", - matches: ["/hello|world|", "hello", "world"], expected: { path: "/hello|world|", - index: 0, params: { foo: "hello", bar: "world" }, }, }, { input: "/hello||", - matches: null, - expected: false, + expected: { path: "/hello||", params: { foo: "hello||" } }, }, ], }, @@ -3146,12 +1484,10 @@ export const MATCH_TESTS: MatchTestSet[] = [ tests: [ { input: "x@y", - matches: ["x@y", "x", "y"], - expected: { path: "x@y", index: 0, params: { foo: "x", bar: "y" } }, + expected: { path: "x@y", params: { foo: "x", bar: "y" } }, }, { input: "x@", - matches: null, expected: false, }, ], @@ -3161,31 +1497,26 @@ export const MATCH_TESTS: MatchTestSet[] = [ * Multi character delimiters. */ { - path: "%25:foo{%25:bar}?", + path: "%25:foo{%25:bar}", options: { delimiter: "%25", }, tests: [ { input: "%25hello", - matches: ["%25hello", "hello", undefined], - expected: { path: "%25hello", index: 0, params: { foo: "hello" } }, + expected: { path: "%25hello", params: { foo: "hello" } }, }, { input: "%25hello%25world", - matches: ["%25hello%25world", "hello", "world"], expected: { path: "%25hello%25world", - index: 0, params: { foo: "hello", bar: "world" }, }, }, { input: "%25555%25222", - matches: ["%25555%25222", "555", "222"], expected: { path: "%25555%25222", - index: 0, params: { foo: "555", bar: "222" }, }, }, 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 d0dd420..c6da631 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -1,80 +1,91 @@ import { describe, it, expect } from "vitest"; -import { pathToRegexp, parse, compile, match } from "./index.js"; +import { parse, compile, match } from "./index.js"; 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 accept an array of keys as the second argument", () => { - const re = pathToRegexp("/user/:id", { end: false }); - - const expectedKeys = [ - { - name: "id", - pattern: undefined, - }, - ]; - - expect(re.keys).toEqual(expectedKeys); - expect(exec(re, "/user/123/show")).toEqual(["/user/123", "123"]); + describe("parse errors", () => { + it("should throw on unbalanced group", () => { + expect(() => parse("/{:foo,")).toThrow( + new TypeError( + "Unexpected END at 7, expected }: https://git.new/pathToRegexpError", + ), + ); + }); + it("should throw on nested unbalanced group", () => { + expect(() => parse("/{:foo/{x,y}")).toThrow( + new TypeError( + "Unexpected END at 12, expected }: https://git.new/pathToRegexpError", + ), + ); }); - it("should accept parse result as input", () => { - const tokens = parse("/user/:id"); - const re = pathToRegexp(tokens); - expect(exec(re, "/user/123")).toEqual(["/user/123", "123"]); + it("should throw on missing param name", () => { + expect(() => parse("/:/")).toThrow( + new TypeError( + "Missing parameter name at 2: https://git.new/pathToRegexpError", + ), + ); }); - it("should throw on non-capturing pattern", () => { - expect(() => { - pathToRegexp("/:foo(?:\\d+(\\.\\d+)?)"); - }).toThrow(new TypeError('Pattern cannot start with "?" at 6')); + it("should throw on missing wildcard name", () => { + expect(() => parse("/*/")).toThrow( + new TypeError( + "Missing parameter name at 2: https://git.new/pathToRegexpError", + ), + ); }); - it("should throw on nested capturing group", () => { - expect(() => { - pathToRegexp("/:foo(\\d+(\\.\\d+)?)"); - }).toThrow(new TypeError("Capturing groups are not allowed at 9")); + it("should throw on unterminated quote", () => { + expect(() => parse('/:"foo')).toThrow( + new TypeError( + "Unterminated quote at 2: https://git.new/pathToRegexpError", + ), + ); }); + }); + + describe("compile errors", () => { + it("should throw when a param is missing", () => { + const toPath = compile("/a/:b/c"); - it("should throw on unbalanced pattern", () => { expect(() => { - pathToRegexp("/:foo(abc"); - }).toThrow(new TypeError("Unbalanced pattern at 5")); + toPath(); + }).toThrow(new TypeError("Missing parameters: b")); }); - it("should throw on missing pattern", () => { + it("should throw when expecting a repeated value", () => { + const toPath = compile("/*foo"); + expect(() => { - pathToRegexp("/:foo()"); - }).toThrow(new TypeError("Missing pattern at 5")); + toPath({ foo: [] }); + }).toThrow(new TypeError('Expected "foo" to be a non-empty array')); }); - it("should throw on missing name", () => { + it("should throw when param gets an array", () => { + const toPath = compile("/:foo"); + expect(() => { - pathToRegexp("/:(test)"); - }).toThrow(new TypeError("Missing parameter name at 2")); + toPath({ foo: [] }); + }).toThrow(new TypeError('Expected "foo" to be a string')); }); - it("should throw on nested groups", () => { + it("should throw when a wildcard is not an array", () => { + const toPath = compile("/*foo"); + expect(() => { - pathToRegexp("/{a{b:foo}}"); - }).toThrow( - new TypeError( - "Unexpected { at 3, expected }: https://git.new/pathToRegexpError", - ), - ); + toPath({ foo: "a" }); + }).toThrow(new TypeError('Expected "foo" to be a non-empty array')); }); - it("should throw on repeat parameters without a separator", () => { + it("should throw when a wildcard array value is not a string", () => { + const toPath = compile("/*foo"); + expect(() => { - pathToRegexp("{:x}*"); - }).toThrow( - new TypeError( - `Missing separator for "x": https://git.new/pathToRegexpError`, - ), - ); + toPath({ foo: [1, "a"] as any }); + }).toThrow(new TypeError('Expected "foo/0" to be a string')); }); }); @@ -106,84 +117,10 @@ describe("path-to-regexp", () => { describe.each(MATCH_TESTS)( "match $path with $options", ({ path, options, tests }) => { - it.each(tests)("should match $input", ({ input, matches, expected }) => { - const re = pathToRegexp(path, options); + it.each(tests)("should match $input", ({ input, expected }) => { const fn = match(path, options); - - expect(exec(re, input)).toEqual(matches); expect(fn(input)).toEqual(expected); }); }, ); - - 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 3f0721a..a63e365 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ const DEFAULT_DELIMITER = "/"; const NOOP_VALUE = (value: string) => value; -const ID_CHAR = /^\p{XID_Continue}$/u; +const ID_START = /^[$_\p{ID_Start}]$/u; +const ID_CONTINUE = /^[$\u200c\u200d\p{ID_Continue}]$/u; const DEBUG_URL = "https://git.new/pathToRegexpError"; /** @@ -14,89 +15,62 @@ 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 PathToRegexpOptions extends ParseOptions { - /** - * Regexp will be case sensitive. (default: `false`) - */ - sensitive?: boolean; - /** - * Allow the delimiter to be arbitrarily repeated. (default: `true`) - */ - loose?: boolean; - /** - * Verify patterns are valid and safe to use. (default: `false`) - */ - strict?: boolean; +export interface MatchOptions { /** - * Match from the beginning of the string. (default: `true`) + * Function for decoding strings for params, or `false` to disable entirely. (default: `decodeURIComponent`) */ - start?: boolean; + decode?: Decode | false; /** - * Match to the end of the string. (default: `true`) + * Matches the path completely without trailing characters. (default: `true`) */ end?: boolean; /** - * Allow optional trailing delimiter to match. (default: `true`) + * Allows optional trailing delimiter to match. (default: `true`) */ trailing?: boolean; -} - -export interface MatchOptions extends PathToRegexpOptions { - /** - * Function for decoding strings for params, or `false` to disable entirely. (default: `decodeURIComponent`) - */ - decode?: Decode | false; -} - -export interface CompileOptions extends ParseOptions { /** - * Regexp will be case sensitive. (default: `false`) + * Match will be case sensitive. (default: `false`) */ sensitive?: boolean; /** - * Allow the delimiter to be arbitrarily repeated. (default: `true`) - */ - loose?: boolean; - /** - * Verify patterns are valid and safe to use. (default: `false`) - */ - strict?: boolean; - /** - * Verifies the function is producing a valid path. (default: `true`) + * 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. @@ -108,117 +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; - } - - if (value === "\\") { - tokens.push({ type: "ESCAPED", index: i++, value: chars[i++] }); - continue; - } - - if (value === ":") { - let name = ""; - - while (ID_CHAR.test(chars[++i])) { - name += chars[i]; - } - - if (!name) { - throw new TypeError(`Missing parameter name at ${i}`); - } - - tokens.push({ type: "NAME", index: i, value: name }); - continue; - } - - if (value === "(") { - const pos = i++; - let count = 1; - let pattern = ""; + function name() { + let value = ""; - if (chars[i] === "?") { - throw new TypeError(`Pattern cannot start with "?" at ${i}`); + 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}`); - } + if (chars[i] === "\\") { + value += chars[++i]; + } else { + value += chars[i]; } - - pattern += chars[i++]; } - if (count) throw new TypeError(`Unbalanced pattern at ${pos}`); - if (!pattern) throw new TypeError(`Missing pattern at ${pos}`); + if (pos) { + throw new TypeError(`Unterminated quote at ${pos}: ${DEBUG_URL}`); + } + } - tokens.push({ type: "PATTERN", index: i, value: pattern }); - continue; + 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]; + + 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 new Iter(tokens); + 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(); @@ -235,94 +212,120 @@ class Iter { } return result; } +} - modifier(): string | undefined { - return this.tryConsume("?") || this.tryConsume("*") || this.tryConsume("+"); - } +/** + * Plain text. + */ +export interface Text { + type: "text"; + value: 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[]; } /** - * Tokenized path instance. Can we passed around instead of string. + * 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: `(?:(?!${escape(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; } + } + + const tokens = consume("END"); + return new TokenData(tokens); +} - it.consume("END"); - break; - } while (true); +/** + * 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; + }; } /** @@ -330,125 +333,95 @@ export function parse(str: string, options: ParseOptions = {}): TokenData { */ export function compile

( path: Path, - options: CompileOptions = {}, + options: CompileOptions & ParseOptions = {}, ) { - const data = path instanceof TokenData ? path : parse(path, options); - return compileTokens

(data, options); + return $compile

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

= (data?: P) => string; +function tokensToFunction( + tokens: Token[], + delimiter: string, + encode: Encode | false, +) { + const encoders = tokens.map((token) => + tokenToFunction(token, delimiter, encode), + ); + + 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 tokenToFunction( token: Token, + delimiter: string, encode: Encode | false, -): (data: ParamData) => string { - if (typeof token === "string") { - return () => token; - } +): (data: ParamData) => string[] { + if (token.type === "text") return () => [token.value]; - const encodeValue = encode || NOOP_VALUE; - const repeated = token.modifier === "+" || token.modifier === "*"; - const optional = token.modifier === "?" || token.modifier === "*"; - const { prefix = "", suffix = "", separator = suffix + prefix } = token; - - if (encode && repeated) { - const stringify = (value: string, index: number) => { - if (typeof value !== "string") { - throw new TypeError(`Expected "${token.name}/${index}" to be a string`); - } - return encodeValue(value); - }; - - const compile = (value: unknown) => { - if (!Array.isArray(value)) { - throw new TypeError(`Expected "${token.name}" to be an array`); - } - - 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 (optional) { - return (data): string => { - const value = data[token.name]; - if (value == null) return ""; - return value.length ? compile(value) : ""; - }; - } + const encodeValue = encode || NOOP_VALUE; - return (data): string => { + if (token.type === "wildcard" && encode !== false) { + return (data) => { const value = data[token.name]; - return compile(value); - }; - } + if (value == null) return ["", token.name]; - const stringify = (value: unknown) => { - if (typeof value !== "string") { - throw new TypeError(`Expected "${token.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 (optional) { - return (data): string => { - const value = data[token.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 => { + return (data) => { const value = data[token.name]; - return stringify(value); - }; -} + if (value == null) return ["", token.name]; -/** - * Transform tokens into a path building function. - */ -function compileTokens

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

{ - const { - encode = encodeURIComponent, - loose = true, - validate = true, - strict = false, - } = options; - const flags = toFlags(options); - const stringify = toStringify(loose, data.delimiter); - const sources = toRegExpSource(data, stringify, [], flags, strict); - - // Compile all the tokens into regexps. - const encoders: Array<(data: ParamData) => string> = data.tokens.map( - (token, index) => { - const fn = tokenToFunction(token, encode); - if (!validate || typeof token === "string") 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)]; }; } @@ -457,7 +430,6 @@ function compileTokens

( */ export interface MatchResult

{ path: string; - index: number; params: P; } @@ -474,220 +446,152 @@ export type MatchFunction

= (path: string) => Match

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

( - path: Path, +function $match

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

{ - const { decode = decodeURIComponent, loose = true } = options; - const data = path instanceof TokenData ? path : parse(path, options); - const stringify = toStringify(loose, data.delimiter); - const keys: Key[] = []; - const re = tokensToRegexp(data, keys, options); + const { + decode = decodeURIComponent, + delimiter = DEFAULT_DELIMITER, + end = true, + trailing = true, + } = options; + const flags = toFlags(options); + const sources: string[] = []; + const keys: Array = []; - const decoders = keys.map((key) => { - if (decode && (key.modifier === "+" || key.modifier === "*")) { - const { prefix = "", suffix = "", separator = suffix + prefix } = key; - const re = new RegExp(stringify(separator), "g"); - return (value: string) => value.split(re).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 || NOOP_VALUE; - }); + let pattern = `^(?:${sources.join("|")})`; + if (trailing) pattern += `(?:${escape(delimiter)}$)?`; + pattern += end ? "$" : `(?=${escape(delimiter)}|$)`; - return function match(input: string) { - const m = re.exec(input); - if (!m) return false; + 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); + }); - const { 0: path, index } = m; - const params = Object.create(null); + return Object.assign( + function match(input: string) { + const m = re.exec(input); + if (!m) return false; - for (let i = 1; i < m.length; i++) { - if (m[i] === undefined) continue; + const { 0: path } = m; + const params = Object.create(null); - const key = keys[i - 1]; - const decoder = decoders[i - 1]; - params[key.name] = decoder(m[i]); - } + for (let i = 1; i < m.length; i++) { + if (m[i] === undefined) continue; - return { path, index, params }; - }; -} + const key = keys[i - 1]; + const decoder = decoders[i - 1]; + params[key.name] = decoder(m[i]); + } -/** - * Escape a regular expression string. - */ -function escape(str: string) { - return str.replace(/([.+*?^${}()[\]|/\\])/g, "\\$1"); + return { path, params }; + }, + { re }, + ); } -/** - * Escape and repeat loose characters for regular expressions. - */ -function looseReplacer(value: string, loose: string) { - const escaped = escape(value); - return loose ? `(?:${escaped})+(?!${escaped})` : escaped; -} +export type Path = string | TokenData; -/** - * Encode all non-delimiter characters using the encode function. - */ -function toStringify(loose: boolean, delimiter: string) { - if (!loose) return escape; +export function match

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

{ + const paths = Array.isArray(path) ? path : [path]; + const items = paths.map((path) => + path instanceof TokenData ? path : parse(path, options), + ); - const re = new RegExp(`(?:(?!${escape(delimiter)}).)+|(.)`, "g"); - return (value: string) => value.replace(re, looseReplacer); + return $match(items, options); } /** - * Get the flags for a regexp from the options. + * Flattened token set. */ -function toFlags(options: { sensitive?: boolean }) { - return options.sensitive ? "" : "i"; -} +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]; -/** - * Expose a function for taking tokens and returning a RegExp. - */ -function tokensToRegexp( - data: TokenData, - keys: Key[], - options: PathToRegexpOptions, -): RegExp { - const { - trailing = true, - loose = true, - start = true, - end = true, - strict = false, - } = options; - const flags = toFlags(options); - const stringify = toStringify(loose, data.delimiter); - const sources = toRegExpSource(data, stringify, keys, flags, strict); - let pattern = start ? "^" : ""; - pattern += sources.join(""); - if (trailing) pattern += `(?:${stringify(data.delimiter)})?`; - pattern += end ? "$" : `(?=${escape(data.delimiter)}|$)`; - - return new RegExp(pattern, flags); + 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, - stringify: Encode, - keys: Key[], - flags: string, - strict: boolean, -): string[] { - const defaultPattern = `(?:(?!${escape(data.delimiter)}).)+?`; +function sequenceToRegExp( + tokens: Flattened[], + delimiter: string, + keys: Array, +): string { + let result = ""; let backtrack = ""; - let safe = true; + let isSafeSegmentParam = true; - return data.tokens.map((token, index) => { - if (typeof token === "string") { - backtrack = token; - return stringify(token); + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + + 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 = stringify(prefix); - const post = stringify(suffix); - - if (token.name) { - const pattern = token.pattern ? `(?:${token.pattern})` : defaultPattern; - const re = checkPattern(pattern, token.name, flags); - - safe ||= safePattern(re, prefix || backtrack); - if (!safe) { - throw new TypeError( - `Ambiguous pattern 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}`); } - safe = !strict || safePattern(re, suffix); - backtrack = ""; - - keys.push(token); - - if (modifier === "+" || modifier === "*") { - const mod = modifier === "*" ? "?" : ""; - const sep = stringify(separator); - - if (!sep) { - throw new TypeError( - `Missing separator for "${token.name}": ${DEBUG_URL}`, - ); - } - safe ||= !strict || safePattern(re, separator); - if (!safe) { - throw new TypeError( - `Ambiguous pattern for "${token.name}" separator: ${DEBUG_URL}`, - ); - } - safe = !strict; - - return `(?:${pre}(${pattern}(?:${sep}${pattern})*)${post})${mod}`; + if (token.type === "param") { + result += `(${negate(delimiter, isSafeSegmentParam ? "" : backtrack)}+)`; + } else { + result += `(.+)`; } - return `(?:${pre}(${pattern})${post})${modifier}`; + keys.push(token); + backtrack = ""; + isSafeSegmentParam = false; + continue; } - - return `(?:${pre}${post})${modifier}`; - }); -} - -function checkPattern(pattern: string, name: string, flags: string) { - try { - return new RegExp(`^${pattern}$`, flags); - } catch (err: any) { - throw new TypeError(`Invalid pattern for "${name}": ${err.message}`); } -} -function safePattern(re: RegExp, value: string) { - return value ? !re.test(value) : false; + return result; } -/** - * Repeated and simple input types. - */ -export type Path = string | TokenData; - -/** - * Normalize the given path string, returning a regular expression. - * - * An empty array can be passed in for the keys, which will hold the - * placeholder key descriptions. For example, using `/user/:id`, `keys` will - * contain `[{ name: 'id', delimiter: '/', optional: false, repeat: false }]`. - */ -export function pathToRegexp(path: Path, options: PathToRegexpOptions = {}) { - const data = path instanceof TokenData ? path : parse(path, options); - const keys: Key[] = []; - const regexp = tokensToRegexp(data, keys, options); - return Object.assign(regexp, { keys }); +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("|")}).)`; } 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"] } diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 0000000..f98f607 --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,10 @@ +/// +import { defineConfig } from "vite"; + +export default defineConfig({ + test: { + coverage: { + exclude: ["scripts/**"], + }, + }, +});