diff --git a/src/cases.spec.ts b/src/cases.spec.ts index 6a7aeec..8e2e2fd 100644 --- a/src/cases.spec.ts +++ b/src/cases.spec.ts @@ -41,64 +41,99 @@ export interface MatchTestSet { export const PARSER_TESTS: ParserTestSet[] = [ { path: "/", - expected: new TokenData([{ type: "text", value: "/" }]), + expected: new TokenData([{ type: "text", value: "/" }], "/"), }, { path: "/:test", - expected: new TokenData([ - { type: "text", value: "/" }, - { type: "param", name: "test" }, - ]), + expected: new TokenData( + [ + { type: "text", value: "/" }, + { type: "param", name: "test" }, + ], + "/:test", + ), + }, + { + path: "/:a:b", + expected: new TokenData( + [ + { type: "text", value: "/" }, + { type: "param", name: "a" }, + { type: "param", name: "b" }, + ], + "/:a:b", + ), }, { path: '/:"0"', - expected: new TokenData([ - { type: "text", value: "/" }, - { type: "param", name: "0" }, - ]), + expected: new TokenData( + [ + { type: "text", value: "/" }, + { type: "param", name: "0" }, + ], + '/:"0"', + ), }, { path: "/:_", - expected: new TokenData([ - { type: "text", value: "/" }, - { type: "param", name: "_" }, - ]), + expected: new TokenData( + [ + { type: "text", value: "/" }, + { type: "param", name: "_" }, + ], + "/:_", + ), }, { path: "/:café", - expected: new TokenData([ - { type: "text", value: "/" }, - { type: "param", name: "café" }, - ]), + expected: new TokenData( + [ + { type: "text", value: "/" }, + { type: "param", name: "café" }, + ], + "/:café", + ), }, { path: '/:"123"', - expected: new TokenData([ - { type: "text", value: "/" }, - { type: "param", name: "123" }, - ]), + expected: new TokenData( + [ + { type: "text", value: "/" }, + { type: "param", name: "123" }, + ], + '/:"123"', + ), }, { path: '/:"1\\"\\2\\"3"', - expected: new TokenData([ - { type: "text", value: "/" }, - { type: "param", name: '1"2"3' }, - ]), + expected: new TokenData( + [ + { type: "text", value: "/" }, + { type: "param", name: '1"2"3' }, + ], + '/:"1\\"\\2\\"3"', + ), }, { path: "/*path", - expected: new TokenData([ - { type: "text", value: "/" }, - { type: "wildcard", name: "path" }, - ]), + expected: new TokenData( + [ + { type: "text", value: "/" }, + { type: "wildcard", name: "path" }, + ], + "/*path", + ), }, { path: '/:"test"stuff', - expected: new TokenData([ - { type: "text", value: "/" }, - { type: "param", name: "test" }, - { type: "text", value: "stuff" }, - ]), + expected: new TokenData( + [ + { type: "text", value: "/" }, + { type: "param", name: "test" }, + { type: "text", value: "stuff" }, + ], + '/:"test"stuff', + ), }, ]; diff --git a/src/index.spec.ts b/src/index.spec.ts index cef557f..835b710 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -1,5 +1,12 @@ import { describe, it, expect } from "vitest"; -import { parse, compile, match, stringify } from "./index.js"; +import { + parse, + compile, + match, + stringify, + pathToRegexp, + TokenData, +} from "./index.js"; import { PARSER_TESTS, COMPILE_TESTS, @@ -15,14 +22,15 @@ describe("path-to-regexp", () => { it("should throw on unbalanced group", () => { expect(() => parse("/{:foo,")).toThrow( new TypeError( - "Unexpected END at 7, expected }: https://git.new/pathToRegexpError", + "Unexpected END at index 7, expected }: /{:foo,; visit https://git.new/pathToRegexpError for more info", ), ); }); + it("should throw on nested unbalanced group", () => { expect(() => parse("/{:foo/{x,y}")).toThrow( new TypeError( - "Unexpected END at 12, expected }: https://git.new/pathToRegexpError", + "Unexpected END at index 12, expected }: /{:foo/{x,y}; visit https://git.new/pathToRegexpError for more info", ), ); }); @@ -30,7 +38,7 @@ describe("path-to-regexp", () => { it("should throw on missing param name", () => { expect(() => parse("/:/")).toThrow( new TypeError( - "Missing parameter name at 2: https://git.new/pathToRegexpError", + "Missing parameter name at index 2: /:/; visit https://git.new/pathToRegexpError for more info", ), ); }); @@ -38,7 +46,7 @@ describe("path-to-regexp", () => { it("should throw on missing wildcard name", () => { expect(() => parse("/*/")).toThrow( new TypeError( - "Missing parameter name at 2: https://git.new/pathToRegexpError", + "Missing parameter name at index 2: /*/; visit https://git.new/pathToRegexpError for more info", ), ); }); @@ -46,7 +54,7 @@ describe("path-to-regexp", () => { it("should throw on unterminated quote", () => { expect(() => parse('/:"foo')).toThrow( new TypeError( - "Unterminated quote at 2: https://git.new/pathToRegexpError", + 'Unterminated quote at index 2: /:"foo; visit https://git.new/pathToRegexpError for more info', ), ); }); @@ -94,6 +102,49 @@ describe("path-to-regexp", () => { }); }); + describe("pathToRegexp errors", () => { + it("should throw when missing text between params", () => { + expect(() => pathToRegexp("/:foo:bar")).toThrow( + new TypeError( + 'Missing text before "bar": /:foo:bar; visit https://git.new/pathToRegexpError for more info', + ), + ); + }); + + it("should throw when missing text between params using TokenData", () => { + expect(() => + pathToRegexp( + new TokenData([ + { type: "param", name: "a" }, + { type: "param", name: "b" }, + ]), + ), + ).toThrow( + new TypeError( + 'Missing text before "b"; visit https://git.new/pathToRegexpError for more info', + ), + ); + }); + + it("should throw with `originalPath` when missing text between params using TokenData", () => { + expect(() => + pathToRegexp( + new TokenData( + [ + { type: "param", name: "a" }, + { type: "param", name: "b" }, + ], + "/[a][b]", + ), + ), + ).toThrow( + new TypeError( + 'Missing text before "b": /[a][b]; visit https://git.new/pathToRegexpError for more info', + ), + ); + }); + }); + describe.each(PARSER_TESTS)( "parse $path with $options", ({ path, options, expected }) => { diff --git a/src/index.ts b/src/index.ts index c178797..0e5e005 100644 --- a/src/index.ts +++ b/src/index.ts @@ -112,6 +112,16 @@ function escape(str: string) { return str.replace(/[.+*?^${}()[\]|/\\]/g, "\\$&"); } +/** + * Format error so it's easier to debug. + */ +function errorMessage(message: string, originalPath: string | undefined) { + if (originalPath) { + return `${message}: ${originalPath}; visit ${DEBUG_URL} for more info`; + } + return `${message}; visit ${DEBUG_URL} for more info`; +} + /** * Tokenize input string. */ @@ -145,12 +155,16 @@ function* lexer(str: string): Generator { } if (pos) { - throw new TypeError(`Unterminated quote at ${pos}: ${DEBUG_URL}`); + throw new TypeError( + errorMessage(`Unterminated quote at index ${pos}`, str), + ); } } if (!value) { - throw new TypeError(`Missing parameter name at ${i}: ${DEBUG_URL}`); + throw new TypeError( + errorMessage(`Missing parameter name at index ${i}`, str), + ); } return value; @@ -180,12 +194,15 @@ function* lexer(str: string): Generator { class Iter { private _peek?: LexToken; + private _tokens: Generator; - constructor(private tokens: Generator) {} + constructor(private originalPath: string) { + this._tokens = lexer(originalPath); + } peek(): LexToken { if (!this._peek) { - const next = this.tokens.next(); + const next = this._tokens.next(); this._peek = next.value; } return this._peek; @@ -203,7 +220,10 @@ class Iter { if (value !== undefined) return value; const { type: nextType, index } = this.peek(); throw new TypeError( - `Unexpected ${nextType} at ${index}, expected ${type}: ${DEBUG_URL}`, + errorMessage( + `Unexpected ${nextType} at index ${index}, expected ${type}`, + this.originalPath, + ), ); } @@ -268,7 +288,10 @@ export type Token = Text | Parameter | Wildcard | Group; * Tokenized path instance. */ export class TokenData { - constructor(public readonly tokens: Token[]) {} + constructor( + public readonly tokens: Token[], + public readonly originalPath?: string, + ) {} } /** @@ -276,7 +299,7 @@ export class TokenData { */ export function parse(str: string, options: ParseOptions = {}): TokenData { const { encodePath = NOOP_VALUE } = options; - const it = new Iter(lexer(str)); + const it = new Iter(str); function consume(endType: TokenType): Token[] { const tokens: Token[] = []; @@ -318,7 +341,7 @@ export function parse(str: string, options: ParseOptions = {}): TokenData { } const tokens = consume("END"); - return new TokenData(tokens); + return new TokenData(tokens, str); } /** @@ -496,12 +519,8 @@ export function pathToRegexp( trailing = true, } = options; const keys: Keys = []; - const sources: string[] = []; const flags = sensitive ? "" : "i"; - - for (const seq of flat(path, options)) { - sources.push(toRegExp(seq, delimiter, keys)); - } + const sources = Array.from(toRegExps(path, delimiter, keys, options)); let pattern = `^(?:${sources.join("|")})`; if (trailing) pattern += `(?:${escape(delimiter)}$)?`; @@ -511,35 +530,39 @@ export function pathToRegexp( return { regexp, keys }; } -/** - * Flattened token set. - */ -type Flattened = Text | Parameter | Wildcard; - /** * Path or array of paths to normalize. */ -function* flat( +function* toRegExps( path: Path | Path[], + delimiter: string, + keys: Keys, options: ParseOptions, -): Generator { +): Generator { if (Array.isArray(path)) { - for (const p of path) yield* flat(p, options); + for (const p of path) yield* toRegExps(p, delimiter, keys, options); return; } const data = path instanceof TokenData ? path : parse(path, options); - yield* flatten(data.tokens, 0, []); + for (const tokens of flatten(data.tokens, 0, [])) { + yield toRegExp(tokens, delimiter, keys, data.originalPath); + } } +/** + * Flattened token set. + */ +type FlatToken = Text | Parameter | Wildcard; + /** * Generate a flat list of sequence tokens from the given tokens. */ function* flatten( tokens: Token[], index: number, - init: Flattened[], -): Generator { + init: FlatToken[], +): Generator { if (index === tokens.length) { return yield init; } @@ -560,7 +583,12 @@ function* flatten( /** * Transform a flat sequence of tokens into a regular expression. */ -function toRegExp(tokens: Flattened[], delimiter: string, keys: Keys) { +function toRegExp( + tokens: FlatToken[], + delimiter: string, + keys: Keys, + originalPath: string | undefined, +) { let result = ""; let backtrack = ""; let isSafeSegmentParam = true; @@ -575,7 +603,9 @@ function toRegExp(tokens: Flattened[], delimiter: string, keys: Keys) { if (token.type === "param" || token.type === "wildcard") { if (!isSafeSegmentParam && !backtrack) { - throw new TypeError(`Missing text after "${token.name}": ${DEBUG_URL}`); + throw new TypeError( + errorMessage(`Missing text before "${token.name}"`, originalPath), + ); } if (token.type === "param") {