diff --git a/package.json b/package.json index 2af940e..f4cf7da 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "size-limit": [ { "path": "dist/index.js", - "limit": "2.2 kB" + "limit": "2 kB" } ], "ts-scripts": { diff --git a/src/cases.spec.ts b/src/cases.spec.ts index 6a7aeec..18d9159 100644 --- a/src/cases.spec.ts +++ b/src/cases.spec.ts @@ -6,6 +6,7 @@ import { type CompileOptions, type ParamData, TokenData, + Path, } from "./index.js"; export interface ParserTestSet { @@ -21,7 +22,7 @@ export interface StringifyTestSet { } export interface CompileTestSet { - path: string; + path: Path; options?: CompileOptions & ParseOptions; tests: Array<{ input: ParamData | undefined; @@ -30,7 +31,7 @@ export interface CompileTestSet { } export interface MatchTestSet { - path: string; + path: Path | Path[]; options?: MatchOptions & ParseOptions; tests: Array<{ input: string; @@ -41,64 +42,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', + ), }, ]; @@ -1609,4 +1645,20 @@ export const MATCH_TESTS: MatchTestSet[] = [ }, ], }, + + /** + * Array input is normalized. + */ + { + path: ["/:foo/:bar", "/:foo/:baz"], + tests: [ + { + input: "/hello/world", + expected: { + path: "/hello/world", + params: { foo: "hello", bar: "world" }, + }, + }, + ], + }, ]; diff --git a/src/index.spec.ts b/src/index.spec.ts index cef557f..9186cc7 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 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 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 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 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 info', ), ); }); @@ -94,6 +102,63 @@ 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 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 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 info', + ), + ); + }); + + it("should contain the error line", () => { + expect.hasAssertions(); + + try { + pathToRegexp("/:"); + } catch (error) { + const stack = (error as Error).stack + ?.split("\n") + .slice(0, 5) + .join("\n"); + expect(stack).toContain("index.spec.ts"); + } + }); + }); + describe.each(PARSER_TESTS)( "parse $path with $options", ({ path, options, expected }) => { diff --git a/src/index.ts b/src/index.ts index c178797..98c7c3c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -113,11 +113,81 @@ function escape(str: string) { } /** - * Tokenize input string. + * Format error so it's easier to debug. */ -function* lexer(str: string): Generator { +function errorMessage(text: string, originalPath: string | undefined) { + let message = text; + if (originalPath !== undefined) message += `: ${originalPath}`; + message += `; visit ${DEBUG_URL} for info`; + return message; +} + +/** + * 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[]; +} + +/** + * A token that corresponds with a regexp capture. + */ +export type Key = Parameter | Wildcard; + +/** + * A sequence of `path-to-regexp` keys that match capturing groups. + */ +export type Keys = Array; + +/** + * 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 originalPath?: string, + ) {} +} + +/** + * Parse a string for the raw tokens. + */ +export function parse(str: string, options: ParseOptions = {}): TokenData { + const { encodePath = NOOP_VALUE } = options; const chars = [...str]; + const tokens: Array = []; let i = 0; + let index = 0; function name() { let value = ""; @@ -145,12 +215,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; @@ -161,131 +235,62 @@ function* lexer(str: string): Generator { const type = SIMPLE_TOKENS[value]; if (type) { - yield { type, index: i++, value }; + tokens.push({ type, index: i++, value }); } else if (value === "\\") { - yield { type: "ESCAPED", index: i++, value: chars[i++] }; + tokens.push({ type: "ESCAPED", index: i++, value: chars[i++] }); } else if (value === ":") { const value = name(); - yield { type: "PARAM", index: i, value }; + tokens.push({ type: "PARAM", index: i, value }); } else if (value === "*") { const value = name(); - yield { type: "WILDCARD", index: i, value }; + tokens.push({ type: "WILDCARD", index: i, value }); } else { - yield { type: "CHAR", index: i, value: chars[i++] }; + tokens.push({ type: "CHAR", index: i, value: chars[i++] }); } } - return { type: "END", index: i, value: "" }; -} - -class Iter { - private _peek?: LexToken; + tokens.push({ type: "END", index: i, value: "" }); - constructor(private tokens: Generator) {} - - peek(): LexToken { - if (!this._peek) { - const next = this.tokens.next(); - this._peek = next.value; - } - return this._peek; + function peek(): LexToken { + return tokens[index]; } - tryConsume(type: TokenType): string | undefined { - const token = this.peek(); + function tryConsume(type: TokenType): string | undefined { + const token = peek(); if (token.type !== type) return; - this._peek = undefined; // Reset after consumed. + index++; return token.value; } - consume(type: TokenType): string { - const value = this.tryConsume(type); + function consume(type: TokenType): string { + const value = tryConsume(type); if (value !== undefined) return value; - const { type: nextType, index } = this.peek(); + const { type: nextType, index } = peek(); throw new TypeError( - `Unexpected ${nextType} at ${index}, expected ${type}: ${DEBUG_URL}`, + errorMessage( + `Unexpected ${nextType} at index ${index}, expected ${type}`, + str, + ), ); } - text(): string { + function text(): string { let result = ""; let value: string | undefined; - while ((value = this.tryConsume("CHAR") || this.tryConsume("ESCAPED"))) { + while ((value = tryConsume("CHAR") || tryConsume("ESCAPED"))) { result += value; } return result; } -} -/** - * 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[]; -} - -/** - * A token that corresponds with a regexp capture. - */ -export type Key = Parameter | Wildcard; - -/** - * A sequence of `path-to-regexp` keys that match capturing groups. - */ -export type Keys = Array; - -/** - * A sequence of path match characters. - */ -export type Token = Text | Parameter | Wildcard | Group; - -/** - * Tokenized path instance. - */ -export class TokenData { - constructor(public readonly tokens: Token[]) {} -} - -/** - * Parse a string for the raw tokens. - */ -export function parse(str: string, options: ParseOptions = {}): TokenData { - const { encodePath = NOOP_VALUE } = options; - const it = new Iter(lexer(str)); - - function consume(endType: TokenType): Token[] { + function consumeUntil(endType: TokenType): Token[] { const tokens: Token[] = []; while (true) { - const path = it.text(); + const path = text(); if (path) tokens.push({ type: "text", value: encodePath(path) }); - const param = it.tryConsume("PARAM"); + const param = tryConsume("PARAM"); if (param) { tokens.push({ type: "param", @@ -294,7 +299,7 @@ export function parse(str: string, options: ParseOptions = {}): TokenData { continue; } - const wildcard = it.tryConsume("WILDCARD"); + const wildcard = tryConsume("WILDCARD"); if (wildcard) { tokens.push({ type: "wildcard", @@ -303,22 +308,21 @@ export function parse(str: string, options: ParseOptions = {}): TokenData { continue; } - const open = it.tryConsume("{"); + const open = tryConsume("{"); if (open) { tokens.push({ type: "group", - tokens: consume("}"), + tokens: consumeUntil("}"), }); continue; } - it.consume(endType); + consume(endType); return tokens; } } - const tokens = consume("END"); - return new TokenData(tokens); + return new TokenData(consumeUntil("END"), str); } /** @@ -496,11 +500,14 @@ export function pathToRegexp( trailing = true, } = options; const keys: Keys = []; - const sources: string[] = []; const flags = sensitive ? "" : "i"; + const sources: string[] = []; - for (const seq of flat(path, options)) { - sources.push(toRegExp(seq, delimiter, keys)); + for (const input of pathsToArray(path, [])) { + const data = input instanceof TokenData ? input : parse(input, options); + for (const tokens of flatten(data.tokens, 0, [])) { + sources.push(toRegExp(tokens, delimiter, keys, data.originalPath)); + } } let pattern = `^(?:${sources.join("|")})`; @@ -512,25 +519,21 @@ export function pathToRegexp( } /** - * Flattened token set. + * Convert a path or array of paths into a flat array. */ -type Flattened = Text | Parameter | Wildcard; +function pathsToArray(paths: Path | Path[], init: Path[]): Path[] { + if (Array.isArray(paths)) { + for (const p of paths) pathsToArray(p, init); + } else { + init.push(paths); + } + return init; +} /** - * Path or array of paths to normalize. + * Flattened token set. */ -function* flat( - path: Path | Path[], - options: ParseOptions, -): Generator { - if (Array.isArray(path)) { - for (const p of path) yield* flat(p, options); - return; - } - - const data = path instanceof TokenData ? path : parse(path, options); - yield* flatten(data.tokens, 0, []); -} +type FlatToken = Text | Parameter | Wildcard; /** * Generate a flat list of sequence tokens from the given tokens. @@ -538,8 +541,8 @@ function* flat( function* flatten( tokens: Token[], index: number, - init: Flattened[], -): Generator { + init: FlatToken[], +): Generator { if (index === tokens.length) { return yield init; } @@ -560,7 +563,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 +583,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") {