From 3f25805f6eef57e60ca5b6775993abc0ab48d72a Mon Sep 17 00:00:00 2001 From: Blake Embrey Date: Sat, 10 May 2025 10:59:42 -0700 Subject: [PATCH 1/7] Add original path to error messages --- src/cases.spec.ts | 92 +++++++++++++++++++++++++++++------------------ src/index.spec.ts | 10 +++--- src/index.ts | 82 ++++++++++++++++++++++++++++-------------- 3 files changed, 119 insertions(+), 65 deletions(-) diff --git a/src/cases.spec.ts b/src/cases.spec.ts index 6a7aeec..2cc6031 100644 --- a/src/cases.spec.ts +++ b/src/cases.spec.ts @@ -41,64 +41,88 @@ 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: '/:"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..76cb160 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -15,14 +15,14 @@ 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 +30,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 +38,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 +46,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', ), ); }); diff --git a/src/index.ts b/src/index.ts index c178797..48281a7 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 after "${token.name}"`, originalPath), + ); } if (token.type === "param") { From 98246be00e96cccafa659b3ff4dfa75b538d3e4f Mon Sep 17 00:00:00 2001 From: Blake Embrey Date: Sat, 10 May 2025 11:12:34 -0700 Subject: [PATCH 2/7] Add more test coverage for error message --- src/cases.spec.ts | 11 ++++++++++ src/index.spec.ts | 53 ++++++++++++++++++++++++++++++++++++++++++++++- src/index.ts | 2 +- 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/cases.spec.ts b/src/cases.spec.ts index 2cc6031..8e2e2fd 100644 --- a/src/cases.spec.ts +++ b/src/cases.spec.ts @@ -53,6 +53,17 @@ export const PARSER_TESTS: ParserTestSet[] = [ "/: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( diff --git a/src/index.spec.ts b/src/index.spec.ts index 76cb160..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, @@ -19,6 +26,7 @@ describe("path-to-regexp", () => { ), ); }); + it("should throw on nested unbalanced group", () => { expect(() => parse("/{:foo/{x,y}")).toThrow( new TypeError( @@ -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 48281a7..0e5e005 100644 --- a/src/index.ts +++ b/src/index.ts @@ -604,7 +604,7 @@ function toRegExp( if (token.type === "param" || token.type === "wildcard") { if (!isSafeSegmentParam && !backtrack) { throw new TypeError( - errorMessage(`Missing text after "${token.name}"`, originalPath), + errorMessage(`Missing text before "${token.name}"`, originalPath), ); } From 2962bc040f8d38b5cffa8b8d9f3ac8776753ab4a Mon Sep 17 00:00:00 2001 From: Blake Embrey Date: Thu, 31 Jul 2025 08:52:21 -0700 Subject: [PATCH 3/7] Update error message --- src/index.spec.ts | 16 ++++++++-------- src/index.ts | 10 +++++----- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/index.spec.ts b/src/index.spec.ts index 835b710..c40f832 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -22,7 +22,7 @@ describe("path-to-regexp", () => { it("should throw on unbalanced group", () => { expect(() => parse("/{:foo,")).toThrow( new TypeError( - "Unexpected END at index 7, expected }: /{:foo,; visit https://git.new/pathToRegexpError for more info", + "Unexpected END at index 7, expected }: /{:foo,; visit https://git.new/pathToRegexpError for info", ), ); }); @@ -30,7 +30,7 @@ describe("path-to-regexp", () => { it("should throw on nested unbalanced group", () => { expect(() => parse("/{:foo/{x,y}")).toThrow( new TypeError( - "Unexpected END at index 12, expected }: /{:foo/{x,y}; visit https://git.new/pathToRegexpError for more info", + "Unexpected END at index 12, expected }: /{:foo/{x,y}; visit https://git.new/pathToRegexpError for info", ), ); }); @@ -38,7 +38,7 @@ describe("path-to-regexp", () => { it("should throw on missing param name", () => { expect(() => parse("/:/")).toThrow( new TypeError( - "Missing parameter name at index 2: /:/; visit https://git.new/pathToRegexpError for more info", + "Missing parameter name at index 2: /:/; visit https://git.new/pathToRegexpError for info", ), ); }); @@ -46,7 +46,7 @@ describe("path-to-regexp", () => { it("should throw on missing wildcard name", () => { expect(() => parse("/*/")).toThrow( new TypeError( - "Missing parameter name at index 2: /*/; visit https://git.new/pathToRegexpError for more info", + "Missing parameter name at index 2: /*/; visit https://git.new/pathToRegexpError for info", ), ); }); @@ -54,7 +54,7 @@ describe("path-to-regexp", () => { it("should throw on unterminated quote", () => { expect(() => parse('/:"foo')).toThrow( new TypeError( - 'Unterminated quote at index 2: /:"foo; visit https://git.new/pathToRegexpError for more info', + 'Unterminated quote at index 2: /:"foo; visit https://git.new/pathToRegexpError for info', ), ); }); @@ -106,7 +106,7 @@ describe("path-to-regexp", () => { 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', + 'Missing text before "bar": /:foo:bar; visit https://git.new/pathToRegexpError for info', ), ); }); @@ -121,7 +121,7 @@ describe("path-to-regexp", () => { ), ).toThrow( new TypeError( - 'Missing text before "b"; visit https://git.new/pathToRegexpError for more info', + 'Missing text before "b"; visit https://git.new/pathToRegexpError for info', ), ); }); @@ -139,7 +139,7 @@ describe("path-to-regexp", () => { ), ).toThrow( new TypeError( - 'Missing text before "b": /[a][b]; visit https://git.new/pathToRegexpError for more info', + 'Missing text before "b": /[a][b]; visit https://git.new/pathToRegexpError for info', ), ); }); diff --git a/src/index.ts b/src/index.ts index 0e5e005..1508487 100644 --- a/src/index.ts +++ b/src/index.ts @@ -115,11 +115,11 @@ function escape(str: string) { /** * 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`; +function errorMessage(text: string, originalPath: string | undefined) { + let message = text; + if (originalPath !== undefined) message += `: ${originalPath}`; + message += `; visit ${DEBUG_URL} for info`; + return message; } /** From c810cd95c719c24a8d21d14b4a76b13b3d5f3afa Mon Sep 17 00:00:00 2001 From: Blake Embrey Date: Thu, 31 Jul 2025 09:27:53 -0700 Subject: [PATCH 4/7] Reduce stack size for errors --- src/index.spec.ts | 14 ++++++++++ src/index.ts | 69 ++++++++++++++++++++++++----------------------- 2 files changed, 49 insertions(+), 34 deletions(-) diff --git a/src/index.spec.ts b/src/index.spec.ts index c40f832..f54a23d 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -143,6 +143,20 @@ describe("path-to-regexp", () => { ), ); }); + + it("should contain the error line", () => { + expect.hasAssertions(); + + try { + pathToRegexp("/:"); + } catch (error) { + const stack = (error as Error).stack + ?.split("\n") + .slice(0, 6) + .join("\n"); + expect(stack).toContain("index.spec.ts"); + } + }); }); describe.each(PARSER_TESTS)( diff --git a/src/index.ts b/src/index.ts index 1508487..355ef7b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -125,8 +125,9 @@ function errorMessage(text: string, originalPath: string | undefined) { /** * Tokenize input string. */ -function* lexer(str: string): Generator { +function lexer(str: string): Iter { const chars = [...str]; + const tokens: Array = []; let i = 0; function name() { @@ -175,43 +176,44 @@ 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: "" }; + tokens.push({ type: "END", index: i, value: "" }); + return new Iter(tokens, str); } class Iter { - private _peek?: LexToken; - private _tokens: Generator; + private _tokens: Array; + private _index = 0; - constructor(private originalPath: string) { - this._tokens = lexer(originalPath); + constructor( + tokens: Array, + private originalPath: string, + ) { + this._index = 0; + this._tokens = tokens; } peek(): LexToken { - if (!this._peek) { - const next = this._tokens.next(); - this._peek = next.value; - } - return this._peek; + return this._tokens[this._index]; } tryConsume(type: TokenType): string | undefined { const token = this.peek(); if (token.type !== type) return; - this._peek = undefined; // Reset after consumed. + this._index++; return token.value; } @@ -299,7 +301,7 @@ export class TokenData { */ export function parse(str: string, options: ParseOptions = {}): TokenData { const { encodePath = NOOP_VALUE } = options; - const it = new Iter(str); + const it = lexer(str); function consume(endType: TokenType): Token[] { const tokens: Token[] = []; @@ -520,7 +522,14 @@ export function pathToRegexp( } = options; const keys: Keys = []; const flags = sensitive ? "" : "i"; - const sources = Array.from(toRegExps(path, delimiter, keys, options)); + const sources: string[] = []; + + 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("|")})`; if (trailing) pattern += `(?:${escape(delimiter)}$)?`; @@ -531,23 +540,15 @@ export function pathToRegexp( } /** - * Path or array of paths to normalize. + * Convert a path or array of paths into a flat array. */ -function* toRegExps( - path: Path | Path[], - delimiter: string, - keys: Keys, - options: ParseOptions, -): Generator { - if (Array.isArray(path)) { - for (const p of path) yield* toRegExps(p, delimiter, keys, options); - return; - } - - const data = path instanceof TokenData ? path : parse(path, options); - for (const tokens of flatten(data.tokens, 0, [])) { - yield toRegExp(tokens, delimiter, keys, data.originalPath); +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; } /** From 35ff289fbfd14292c99dce11735c3b129b87993a Mon Sep 17 00:00:00 2001 From: Blake Embrey Date: Thu, 31 Jul 2025 09:31:37 -0700 Subject: [PATCH 5/7] Test coverage for array --- src/cases.spec.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/cases.spec.ts b/src/cases.spec.ts index 8e2e2fd..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; @@ -1644,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" }, + }, + }, + ], + }, ]; From 1bc7e96236bf371f206b5b6e04107d8b91b4a81a Mon Sep 17 00:00:00 2001 From: Blake Embrey Date: Thu, 31 Jul 2025 12:04:18 -0700 Subject: [PATCH 6/7] Remove another function in stack --- src/index.spec.ts | 2 +- src/index.ts | 146 ++++++++++++++++++++++------------------------ 2 files changed, 70 insertions(+), 78 deletions(-) diff --git a/src/index.spec.ts b/src/index.spec.ts index f54a23d..9186cc7 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -152,7 +152,7 @@ describe("path-to-regexp", () => { } catch (error) { const stack = (error as Error).stack ?.split("\n") - .slice(0, 6) + .slice(0, 5) .join("\n"); expect(stack).toContain("index.spec.ts"); } diff --git a/src/index.ts b/src/index.ts index 355ef7b..9835698 100644 --- a/src/index.ts +++ b/src/index.ts @@ -122,78 +122,6 @@ function errorMessage(text: string, originalPath: string | undefined) { return message; } -/** - * Tokenize input string. - */ -function lexer(str: string): Iter { - const chars = [...str]; - const tokens: Array = []; - let i = 0; - - function name() { - let value = ""; - - 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] === '"') { - i++; - pos = 0; - break; - } - - if (chars[i] === "\\") { - value += chars[++i]; - } else { - value += chars[i]; - } - } - - if (pos) { - throw new TypeError( - errorMessage(`Unterminated quote at index ${pos}`, str), - ); - } - } - - if (!value) { - throw new TypeError( - errorMessage(`Missing parameter name at index ${i}`, str), - ); - } - - return value; - } - - while (i < chars.length) { - const value = chars[i]; - const type = SIMPLE_TOKENS[value]; - - if (type) { - tokens.push({ type, index: i++, value }); - } else if (value === "\\") { - tokens.push({ type: "ESCAPED", index: i++, value: chars[i++] }); - } else if (value === ":") { - const value = name(); - tokens.push({ type: "PARAM", index: i, value }); - } else if (value === "*") { - const value = name(); - tokens.push({ type: "WILDCARD", index: i, value }); - } else { - tokens.push({ type: "CHAR", index: i, value: chars[i++] }); - } - } - - tokens.push({ type: "END", index: i, value: "" }); - return new Iter(tokens, str); -} - class Iter { private _tokens: Array; private _index = 0; @@ -301,9 +229,73 @@ export class TokenData { */ export function parse(str: string, options: ParseOptions = {}): TokenData { const { encodePath = NOOP_VALUE } = options; - const it = lexer(str); + const chars = [...str]; + const tokens: Array = []; + let i = 0; + + function name() { + let value = ""; + + 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] === '"') { + i++; + pos = 0; + break; + } + + if (chars[i] === "\\") { + value += chars[++i]; + } else { + value += chars[i]; + } + } + + if (pos) { + throw new TypeError( + errorMessage(`Unterminated quote at index ${pos}`, str), + ); + } + } + + if (!value) { + throw new TypeError( + errorMessage(`Missing parameter name at index ${i}`, str), + ); + } + + return value; + } + + while (i < chars.length) { + const value = chars[i]; + const type = SIMPLE_TOKENS[value]; + + if (type) { + tokens.push({ type, index: i++, value }); + } else if (value === "\\") { + tokens.push({ type: "ESCAPED", index: i++, value: chars[i++] }); + } else if (value === ":") { + const value = name(); + tokens.push({ type: "PARAM", index: i, value }); + } else if (value === "*") { + const value = name(); + tokens.push({ type: "WILDCARD", index: i, value }); + } else { + tokens.push({ type: "CHAR", index: i, value: chars[i++] }); + } + } + + tokens.push({ type: "END", index: i, value: "" }); - function consume(endType: TokenType): Token[] { + function consume(it: Iter, endType: TokenType): Token[] { const tokens: Token[] = []; while (true) { @@ -332,7 +324,7 @@ export function parse(str: string, options: ParseOptions = {}): TokenData { if (open) { tokens.push({ type: "group", - tokens: consume("}"), + tokens: consume(it, "}"), }); continue; } @@ -342,8 +334,8 @@ export function parse(str: string, options: ParseOptions = {}): TokenData { } } - const tokens = consume("END"); - return new TokenData(tokens, str); + const it = new Iter(tokens, str); + return new TokenData(consume(it, "END"), str); } /** From 85e5e2cb62ea4a8e77255c28ce2aefe34db3f8e3 Mon Sep 17 00:00:00 2001 From: Blake Embrey Date: Thu, 31 Jul 2025 13:55:02 -0700 Subject: [PATCH 7/7] Prune some bytes --- package.json | 2 +- src/index.ts | 95 +++++++++++++++++++++++----------------------------- 2 files changed, 42 insertions(+), 55 deletions(-) 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/index.ts b/src/index.ts index 9835698..98c7c3c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -122,51 +122,6 @@ function errorMessage(text: string, originalPath: string | undefined) { return message; } -class Iter { - private _tokens: Array; - private _index = 0; - - constructor( - tokens: Array, - private originalPath: string, - ) { - this._index = 0; - this._tokens = tokens; - } - - peek(): LexToken { - return this._tokens[this._index]; - } - - tryConsume(type: TokenType): string | undefined { - const token = this.peek(); - if (token.type !== type) return; - this._index++; - return token.value; - } - - consume(type: TokenType): string { - const value = this.tryConsume(type); - if (value !== undefined) return value; - const { type: nextType, index } = this.peek(); - throw new TypeError( - errorMessage( - `Unexpected ${nextType} at index ${index}, expected ${type}`, - this.originalPath, - ), - ); - } - - text(): string { - let result = ""; - let value: string | undefined; - while ((value = this.tryConsume("CHAR") || this.tryConsume("ESCAPED"))) { - result += value; - } - return result; - } -} - /** * Plain text. */ @@ -232,6 +187,7 @@ export function parse(str: string, options: ParseOptions = {}): TokenData { const chars = [...str]; const tokens: Array = []; let i = 0; + let index = 0; function name() { let value = ""; @@ -295,14 +251,46 @@ export function parse(str: string, options: ParseOptions = {}): TokenData { tokens.push({ type: "END", index: i, value: "" }); - function consume(it: Iter, endType: TokenType): Token[] { + function peek(): LexToken { + return tokens[index]; + } + + function tryConsume(type: TokenType): string | undefined { + const token = peek(); + if (token.type !== type) return; + index++; + return token.value; + } + + function consume(type: TokenType): string { + const value = tryConsume(type); + if (value !== undefined) return value; + const { type: nextType, index } = peek(); + throw new TypeError( + errorMessage( + `Unexpected ${nextType} at index ${index}, expected ${type}`, + str, + ), + ); + } + + function text(): string { + let result = ""; + let value: string | undefined; + while ((value = tryConsume("CHAR") || tryConsume("ESCAPED"))) { + result += value; + } + return result; + } + + 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", @@ -311,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", @@ -320,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(it, "}"), + tokens: consumeUntil("}"), }); continue; } - it.consume(endType); + consume(endType); return tokens; } } - const it = new Iter(tokens, str); - return new TokenData(consume(it, "END"), str); + return new TokenData(consumeUntil("END"), str); } /**