From 389540f96d062a9b58ff20e5dc40dd90110bd3c0 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Tue, 9 Dec 2025 22:41:46 -0500 Subject: [PATCH 01/54] flat AST parsing --- packages/route-pattern/src/lib2/part/ast.ts | 11 +++ .../route-pattern/src/lib2/part/parse.test.ts | 31 ++++++ packages/route-pattern/src/lib2/part/parse.ts | 97 +++++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 packages/route-pattern/src/lib2/part/ast.ts create mode 100644 packages/route-pattern/src/lib2/part/parse.test.ts create mode 100644 packages/route-pattern/src/lib2/part/parse.ts diff --git a/packages/route-pattern/src/lib2/part/ast.ts b/packages/route-pattern/src/lib2/part/ast.ts new file mode 100644 index 00000000000..7c90aaf5999 --- /dev/null +++ b/packages/route-pattern/src/lib2/part/ast.ts @@ -0,0 +1,11 @@ +export type AST = { + tokens: Array + paramNames: Array + optionals: Map +} + +type Token = + | { type: 'text'; text: string } + | { type: '(' | ')' } + | { type: ':'; nameIndex: number } + | { type: '*'; nameIndex?: number } diff --git a/packages/route-pattern/src/lib2/part/parse.test.ts b/packages/route-pattern/src/lib2/part/parse.test.ts new file mode 100644 index 00000000000..35ee9f04730 --- /dev/null +++ b/packages/route-pattern/src/lib2/part/parse.test.ts @@ -0,0 +1,31 @@ +import * as assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { parse } from './parse.ts' + +describe('parse', () => { + it('creates an AST', () => { + let source = 'api/(v:major(.:minor)/)run' + let ast = parse(source) + assert.deepEqual(ast, { + tokens: [ + { type: 'text', text: 'api/' }, + { type: '(' }, + { type: 'text', text: 'v' }, + { type: ':', nameIndex: 0 }, + { type: '(' }, + { type: 'text', text: '.' }, + { type: ':', nameIndex: 1 }, + { type: ')' }, + { type: 'text', text: '/' }, + { type: ')' }, + { type: 'text', text: 'run' }, + ], + paramNames: ['major', 'minor'], + optionals: new Map([ + [1, 9], + [4, 7], + ]), + }) + }) +}) diff --git a/packages/route-pattern/src/lib2/part/parse.ts b/packages/route-pattern/src/lib2/part/parse.ts new file mode 100644 index 00000000000..ec9d1b72e5a --- /dev/null +++ b/packages/route-pattern/src/lib2/part/parse.ts @@ -0,0 +1,97 @@ +import type { AST } from './ast' + +type Span = [begin: number, end: number] + +const identifierRE = /^[a-zA-Z_$][a-zA-Z_$0-9]*/ + +export function parse(source: string, span?: Span): AST { + span ??= [0, source.length] + + let ast: AST = { + tokens: [], + paramNames: [], + optionals: new Map(), + } + + let appendText = (text: string) => { + let currentToken = ast.tokens.at(-1) + if (currentToken?.type === 'text') { + currentToken.text += text + } else { + ast.tokens.push({ type: 'text', text }) + } + } + + let i = span[0] + let optionalStack: Array = [] + while (i < span[1]) { + let char = source[i] + + // optional begin + if (char === '(') { + optionalStack.push(ast.tokens.length) + ast.tokens.push({ type: char }) + i += 1 + continue + } + + // optional end + if (char === ')') { + let begin = optionalStack.pop() + if (!begin) { + throw new Error(`unmatched ) at ${i}`) + } + ast.optionals.set(begin, ast.tokens.length) + ast.tokens.push({ type: char }) + i += 1 + continue + } + + // variable + if (char === ':') { + i += 1 + let name = identifierRE.exec(source.slice(i, span[1]))?.[0] + if (!name) { + throw new Error(`missing variable name at ${i}`) + } + ast.tokens.push({ type: ':', nameIndex: ast.paramNames.length }) + ast.paramNames.push(name) + i += name.length + continue + } + + // wildcard + if (char === '*') { + i += 1 + let name = identifierRE.exec(source.slice(i, span[1]))?.[0] + if (name) { + ast.tokens.push({ type: '*', nameIndex: ast.paramNames.length }) + ast.paramNames.push(name) + i += name.length + } else { + ast.tokens.push({ type: '*' }) + } + continue + } + + // escaped char + if (char === '\\') { + if (i + 1 === span[1]) { + throw new Error(`dangling escape at ${i}`) + } + let text = source.slice(i, i + 2) + appendText(text) + i += text.length + continue + } + + // text + appendText(char) + i += 1 + } + if (optionalStack.length > 0) { + throw new Error(`unmatched ( at ${optionalStack.at(-1)!}`) + } + + return ast +} From 50095d56aab814258bf99c80c81ae29ea274457a Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Tue, 9 Dec 2025 22:42:19 -0500 Subject: [PATCH 02/54] variants --- .../src/lib2/part/variants.test.ts | 13 +++++ .../route-pattern/src/lib2/part/variants.ts | 47 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 packages/route-pattern/src/lib2/part/variants.test.ts create mode 100644 packages/route-pattern/src/lib2/part/variants.ts diff --git a/packages/route-pattern/src/lib2/part/variants.test.ts b/packages/route-pattern/src/lib2/part/variants.test.ts new file mode 100644 index 00000000000..a3e36d51731 --- /dev/null +++ b/packages/route-pattern/src/lib2/part/variants.test.ts @@ -0,0 +1,13 @@ +import * as assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { parse } from './parse.ts' +import { variants } from './variants.ts' + +describe('variants', () => { + it('returns all possible combinations of optionals', () => { + let source = 'api/(v:major(.:minor)/)run' + let ast = parse(source) + assert.deepEqual(variants(ast), ['api/run', 'api/v{:0}/run', 'api/v{:0}.{:1}/run']) + }) +}) diff --git a/packages/route-pattern/src/lib2/part/variants.ts b/packages/route-pattern/src/lib2/part/variants.ts new file mode 100644 index 00000000000..162e0e67a41 --- /dev/null +++ b/packages/route-pattern/src/lib2/part/variants.ts @@ -0,0 +1,47 @@ +import type { AST } from './ast' + +export function variants(ast: AST): Array { + let result: Array = [] + + let q: Array<{ index: number; variant: string }> = [{ index: 0, variant: '' }] + while (q.length > 0) { + let { index, variant } = q.pop()! + + if (index === ast.tokens.length) { + result.push(variant) + continue + } + + let token = ast.tokens[index] + if (token.type === '(') { + q.push( + { index: index + 1, variant }, // include optional + { index: ast.optionals.get(index)! + 1, variant }, // exclude optional + ) + continue + } + if (token.type === ')') { + q.push({ index: index + 1, variant }) + continue + } + + if (token.type === ':') { + q.push({ index: index + 1, variant: variant + `{:${token.nameIndex}}` }) + continue + } + + if (token.type === '*') { + q.push({ index: index + 1, variant: variant + `{*${token.nameIndex ?? ''}}` }) + continue + } + + if (token.type === 'text') { + q.push({ index: index + 1, variant: variant + token.text }) + continue + } + + throw new Error(`internal: unrecognized token type '${token.type}'`) + } + + return result +} From a350c1c4290bd84124e59e63633060b4ac79498e Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Tue, 9 Dec 2025 22:42:34 -0500 Subject: [PATCH 03/54] regexp --- .../src/lib2/part/to-regexp.test.ts | 68 +++++++++++++++++++ .../route-pattern/src/lib2/part/to-regexp.ts | 44 ++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 packages/route-pattern/src/lib2/part/to-regexp.test.ts create mode 100644 packages/route-pattern/src/lib2/part/to-regexp.ts diff --git a/packages/route-pattern/src/lib2/part/to-regexp.test.ts b/packages/route-pattern/src/lib2/part/to-regexp.test.ts new file mode 100644 index 00000000000..fbb649fe83c --- /dev/null +++ b/packages/route-pattern/src/lib2/part/to-regexp.test.ts @@ -0,0 +1,68 @@ +import * as assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { parse } from './parse.ts' +import { toRegExp } from './to-regexp.ts' + +describe('toRegExp', () => { + it('converts an AST to a regular expression', () => { + let source = 'api/(v:major(.:minor)/)run' + let ast = parse(source) + let paramValueRE = /[^/]+/ + let result = toRegExp(ast, paramValueRE) + + // The regex should match the full pattern + assert.match('api/v1.2/run', result) + assert.match('api/v1/run', result) + assert.match('api/run', result) + + // Should not match patterns that don't fit + assert.doesNotMatch('api/', result) + assert.doesNotMatch('api/v1.2/walk', result) + }) + + it('handles static text', () => { + let source = 'hello/world' + let ast = parse(source) + let paramValueRE = /[^/]+/ + let result = toRegExp(ast, paramValueRE) + + assert.match('hello/world', result) + assert.doesNotMatch('hello/there', result) + }) + + it('handles parameters', () => { + let source = 'users/:id' + let ast = parse(source) + let paramValueRE = /[^/]+/ + let result = toRegExp(ast, paramValueRE) + + assert.match('users/123', result) + assert.match('users/abc', result) + assert.doesNotMatch('users/', result) + assert.doesNotMatch('users/123/posts', result) + }) + + it('handles wildcards', () => { + let source = 'files/*' + let ast = parse(source) + let paramValueRE = /[^/]+/ + let result = toRegExp(ast, paramValueRE) + + assert.match('files/anything', result) + assert.match('files/path/to/file', result) + assert.match('files/', result) + }) + + it('escapes special regex characters in static text', () => { + let source = 'api/v1.0/users/:id/(notes)' + let ast = parse(source) + let paramValueRE = /[^/]+/ + let result = toRegExp(ast, paramValueRE) + + // The literal '.' and parentheses should be escaped + assert.match('api/v1.0/users/123/notes', result) + assert.match('api/v1.0/users/123/', result) + assert.doesNotMatch('api/v1X0/users/123/notes', result) // '.' shouldn't match any char + }) +}) diff --git a/packages/route-pattern/src/lib2/part/to-regexp.ts b/packages/route-pattern/src/lib2/part/to-regexp.ts new file mode 100644 index 00000000000..a9ccffd0c9e --- /dev/null +++ b/packages/route-pattern/src/lib2/part/to-regexp.ts @@ -0,0 +1,44 @@ +import type { AST } from './ast' + +export function toRegExp(ast: AST, paramValueRE: RegExp): RegExp { + let source = toRegExpSource(ast, paramValueRE) + return new RegExp('^' + source + '$') +} + +export function toRegExpSource(ast: AST, paramValueRE: RegExp): string { + let source = '' + + for (let token of ast.tokens) { + if (token.type === '(') { + source += `(?:` + continue + } + + if (token.type === ')') { + source += ')?' + continue + } + + if (token.type === ':') { + source += `(${paramValueRE.source})` + continue + } + + if (token.type === '*') { + source += token.nameIndex === undefined ? '(?:.*)' : '(.*)' + continue + } + + if (token.type === 'text') { + source += escape(token.text) + continue + } + + // todo: make this a type error if `token.type` is not `never` using a custom error + throw new Error(`internal: unrecognized token type '${token.type}'`) + } + return source +} + +/** Polyfill for `RegExp.escape` */ +const escape = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') From 99057dd0c8dbe0bbf1871a8a5e6871040b7f8cd9 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Tue, 9 Dec 2025 22:42:55 -0500 Subject: [PATCH 04/54] bench parse part --- .../route-pattern/bench/parse-part.bench.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 packages/route-pattern/bench/parse-part.bench.ts diff --git a/packages/route-pattern/bench/parse-part.bench.ts b/packages/route-pattern/bench/parse-part.bench.ts new file mode 100644 index 00000000000..b83653da147 --- /dev/null +++ b/packages/route-pattern/bench/parse-part.bench.ts @@ -0,0 +1,52 @@ +/** + * This isn't really apples-to-apples since `lib2` produces an AST + * that is designed for high speed generation of variants and trie branches + * whereas `lib` is a general-purpose AST. + * + * Just want to make sure `lib2` is as fast (or faster) than `lib`, + * but we won't see the full benefits of `lib2` until `lib` implements variant generation + * or until `lib2` has a trie to compare matching perf against `lib`. + */ +import { bench } from 'vitest' + +import { parsePart } from '../src/lib/parse.ts' +import { parse } from '../src/lib2/part/parse.ts' + +let patterns = [ + '/users/:id/posts', + '/products/:id', + '/products/:id/reviews', + '/docs/:category', + '/docs/:category/:page', + '/api/v1/products', + '/api/v1/products/:id', + '/api/v1/users/:userId', + '/posts/:id', + '/posts/:id/comments', + '/posts/:id/comments/:commentId', + '/categories/:category', + '/tags/:tag', + '/users/:userId/posts/:postId', + '/products/:id/reviews/:reviewId', + '/products/:category/:slug', + '/blog/:year/:month/:day/:slug', + '/api/v1/users/:userId/orders/:orderId', + '/docs/:lang/:category/:page', + '/api(/v:version)/orders', + '/api(/v:version)/orders/:orderId', + '/users/:id(.:format)', + '/posts/:slug(.html)', + '/docs(/:section)(/:page)', + '/products/:id(/reviews)', + '/assets/images/*path', + '/downloads/*', + '/files/*path', + '/static/*', +] + +bench('lib', () => { + patterns.forEach((pattern) => parsePart('', '/', pattern, 0, pattern.length)) +}) +bench('lib2', () => { + patterns.forEach((pattern) => parse(pattern)) +}) From 13701d132bcbffef1997ae497168efea5daa29f6 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 10 Dec 2025 10:22:52 -0500 Subject: [PATCH 05/54] span --- packages/route-pattern/src/lib2/part/parse.ts | 3 +-- packages/route-pattern/src/lib2/span.ts | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 packages/route-pattern/src/lib2/span.ts diff --git a/packages/route-pattern/src/lib2/part/parse.ts b/packages/route-pattern/src/lib2/part/parse.ts index ec9d1b72e5a..34ee8a51a86 100644 --- a/packages/route-pattern/src/lib2/part/parse.ts +++ b/packages/route-pattern/src/lib2/part/parse.ts @@ -1,7 +1,6 @@ +import type { Span } from '../span' import type { AST } from './ast' -type Span = [begin: number, end: number] - const identifierRE = /^[a-zA-Z_$][a-zA-Z_$0-9]*/ export function parse(source: string, span?: Span): AST { diff --git a/packages/route-pattern/src/lib2/span.ts b/packages/route-pattern/src/lib2/span.ts new file mode 100644 index 00000000000..0148e1651f8 --- /dev/null +++ b/packages/route-pattern/src/lib2/span.ts @@ -0,0 +1 @@ +export type Span = [begin: number, end: number] From 68e69b970ccfb12798059a3683dba5bdd31e9920 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 10 Dec 2025 10:23:04 -0500 Subject: [PATCH 06/54] split (copied from lib) --- packages/route-pattern/src/lib2/split.ts | 79 ++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 packages/route-pattern/src/lib2/split.ts diff --git a/packages/route-pattern/src/lib2/split.ts b/packages/route-pattern/src/lib2/split.ts new file mode 100644 index 00000000000..917cf0ab962 --- /dev/null +++ b/packages/route-pattern/src/lib2/split.ts @@ -0,0 +1,79 @@ +import type { Span } from './span' + +export interface SplitResult { + protocol: Span | undefined + hostname: Span | undefined + port: Span | undefined + pathname: Span | undefined + search: Span | undefined +} + +/** + * Split a route pattern into protocol, hostname, port, pathname, and search + * ranges. Ranges are [start (inclusive), end (exclusive)]. + */ +export function split(source: source): SplitResult { + let protocol: Span | undefined + let hostname: Span | undefined + let port: Span | undefined + let pathname: Span | undefined + let search: Span | undefined + + // search + let searchStart = source.indexOf('?') + if (searchStart !== -1) { + search = [searchStart + 1, source.length] + source = source.slice(0, searchStart) as source + } + + let index = 0 + let solidusIndex = source.indexOf('://') + if (solidusIndex !== -1) { + // protocol + if (solidusIndex !== 0) { + protocol = [0, solidusIndex] + } + index = solidusIndex + 3 + + // hostname + port + let hostEndIndex = source.indexOf('/', index) + if (hostEndIndex === -1) hostEndIndex = source.length + + // detect port (numeric) at end of host segment + let colonIndex = source.lastIndexOf(':', hostEndIndex - 1) + if (colonIndex !== -1 && colonIndex >= index) { + // Ensure everything after the colon is digits + let isPort = true + for (let i = colonIndex + 1; i < hostEndIndex; i++) { + let char = source.charCodeAt(i) + if (char < 48 /* '0' */ || char > 57 /* '9' */) { + isPort = false + break + } + } + + if (isPort && colonIndex + 1 < hostEndIndex) { + // hostname up to colon, port after colon + hostname = [index, colonIndex] + port = [colonIndex + 1, hostEndIndex] + } else { + hostname = [index, hostEndIndex] + } + } else { + hostname = [index, hostEndIndex] + } + + index = hostEndIndex === source.length ? hostEndIndex : hostEndIndex + 1 + } + + // pathname + if (index !== source.length) { + if (source.charAt(index) === '/') { + index += 1 + } + + pathname = [index, source.length] + } + + return { protocol, hostname, port, pathname, search } +} From 380df7c19e560bd2ad31eccb70bb93d192e5e92a Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 10 Dec 2025 10:23:32 -0500 Subject: [PATCH 07/54] route-pattern --- packages/route-pattern/src/lib2/part/index.ts | 2 + .../src/lib2/route-pattern.test.ts | 69 +++++++++++++++++++ .../route-pattern/src/lib2/route-pattern.ts | 39 +++++++++++ 3 files changed, 110 insertions(+) create mode 100644 packages/route-pattern/src/lib2/part/index.ts create mode 100644 packages/route-pattern/src/lib2/route-pattern.test.ts create mode 100644 packages/route-pattern/src/lib2/route-pattern.ts diff --git a/packages/route-pattern/src/lib2/part/index.ts b/packages/route-pattern/src/lib2/part/index.ts new file mode 100644 index 00000000000..9dad1da976b --- /dev/null +++ b/packages/route-pattern/src/lib2/part/index.ts @@ -0,0 +1,2 @@ +export type { AST } from './ast.ts' +export { parse } from './parse.ts' diff --git a/packages/route-pattern/src/lib2/route-pattern.test.ts b/packages/route-pattern/src/lib2/route-pattern.test.ts new file mode 100644 index 00000000000..0b99a336216 --- /dev/null +++ b/packages/route-pattern/src/lib2/route-pattern.test.ts @@ -0,0 +1,69 @@ +import * as assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { parse } from './route-pattern.ts' + +describe('parse', () => { + it('parses a simple pathname', () => { + let ast = parse('users/:id') + assert.deepEqual(ast, { + protocol: undefined, + hostname: undefined, + port: undefined, + pathname: { + tokens: [ + { type: 'text', text: 'users/' }, + { type: ':', nameIndex: 0 }, + ], + paramNames: ['id'], + optionals: new Map(), + }, + search: undefined, + }) + }) + + it('parses a full URL pattern', () => { + let ast = parse('https://example.com/users/:id') + assert.deepEqual(ast, { + protocol: { + tokens: [{ type: 'text', text: 'https' }], + paramNames: [], + optionals: new Map(), + }, + hostname: { + tokens: [{ type: 'text', text: 'example.com' }], + paramNames: [], + optionals: new Map(), + }, + port: undefined, + pathname: { + tokens: [ + { type: 'text', text: 'users/' }, + { type: ':', nameIndex: 0 }, + ], + paramNames: ['id'], + optionals: new Map(), + }, + search: undefined, + }) + }) + + it('parses protocol and pathname without hostname', () => { + let ast = parse('file:///path/to/file') + assert.deepEqual(ast, { + protocol: { + tokens: [{ type: 'text', text: 'file' }], + paramNames: [], + optionals: new Map(), + }, + hostname: undefined, + port: undefined, + pathname: { + tokens: [{ type: 'text', text: 'path/to/file' }], + paramNames: [], + optionals: new Map(), + }, + search: undefined, + }) + }) +}) diff --git a/packages/route-pattern/src/lib2/route-pattern.ts b/packages/route-pattern/src/lib2/route-pattern.ts new file mode 100644 index 00000000000..721918c0695 --- /dev/null +++ b/packages/route-pattern/src/lib2/route-pattern.ts @@ -0,0 +1,39 @@ +import { split } from './split.ts' +import * as Part from './part/index.ts' + +type AST = { + protocol: Part.AST | undefined + hostname: Part.AST | undefined + port: string | undefined + pathname: Part.AST | undefined + search: string | undefined // todo +} + +export function parse(source: string): AST { + let ast: AST = { + protocol: undefined, + hostname: undefined, + port: undefined, + pathname: undefined, + search: undefined, + } + + let { protocol, hostname, port, pathname, search } = split(source) + + if (protocol && protocol[0] !== protocol[1]) { + ast.protocol = Part.parse(source, protocol) + } + if (hostname && hostname[0] !== hostname[1]) { + ast.hostname = Part.parse(source, hostname) + } + if (port && port[0] !== port[1]) { + ast.port = source.slice(...port) + } + if (pathname && pathname[0] !== pathname[1]) { + ast.pathname = Part.parse(source, pathname) + } + if (search && search[0] !== search[1]) { + ast.search = source.slice(...search) + } + return ast +} From 25eec84c52f897e9ca71c0b730b7d4844f4bd7f1 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 11 Dec 2025 11:28:14 -0500 Subject: [PATCH 08/54] regexp escape --- packages/route-pattern/src/lib2/part/to-regexp.ts | 6 ++---- packages/route-pattern/src/lib2/regexp.ts | 2 ++ 2 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 packages/route-pattern/src/lib2/regexp.ts diff --git a/packages/route-pattern/src/lib2/part/to-regexp.ts b/packages/route-pattern/src/lib2/part/to-regexp.ts index a9ccffd0c9e..c861290164f 100644 --- a/packages/route-pattern/src/lib2/part/to-regexp.ts +++ b/packages/route-pattern/src/lib2/part/to-regexp.ts @@ -1,3 +1,4 @@ +import * as RE from '../regexp.ts' import type { AST } from './ast' export function toRegExp(ast: AST, paramValueRE: RegExp): RegExp { @@ -30,7 +31,7 @@ export function toRegExpSource(ast: AST, paramValueRE: RegExp): string { } if (token.type === 'text') { - source += escape(token.text) + source += RE.escape(token.text) continue } @@ -39,6 +40,3 @@ export function toRegExpSource(ast: AST, paramValueRE: RegExp): string { } return source } - -/** Polyfill for `RegExp.escape` */ -const escape = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') diff --git a/packages/route-pattern/src/lib2/regexp.ts b/packages/route-pattern/src/lib2/regexp.ts new file mode 100644 index 00000000000..cd145714a33 --- /dev/null +++ b/packages/route-pattern/src/lib2/regexp.ts @@ -0,0 +1,2 @@ +/** Polyfill for `RegExp.escape` */ +export const escape = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') From e8255b58deba941caf47dc283d7457c67192d17a Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 11 Dec 2025 11:32:06 -0500 Subject: [PATCH 09/54] variant as key + paramIndices --- .../src/lib2/part/variants.test.ts | 6 +++- .../route-pattern/src/lib2/part/variants.ts | 28 ++++++++++++++----- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/packages/route-pattern/src/lib2/part/variants.test.ts b/packages/route-pattern/src/lib2/part/variants.test.ts index a3e36d51731..8b538cc1fb6 100644 --- a/packages/route-pattern/src/lib2/part/variants.test.ts +++ b/packages/route-pattern/src/lib2/part/variants.test.ts @@ -8,6 +8,10 @@ describe('variants', () => { it('returns all possible combinations of optionals', () => { let source = 'api/(v:major(.:minor)/)run' let ast = parse(source) - assert.deepEqual(variants(ast), ['api/run', 'api/v{:0}/run', 'api/v{:0}.{:1}/run']) + assert.deepEqual(variants(ast), [ + { key: 'api/run', paramIndices: [] }, + { key: 'api/v{:}/run', paramIndices: [0] }, + { key: 'api/v{:}.{:}/run', paramIndices: [0, 1] }, + ]) }) }) diff --git a/packages/route-pattern/src/lib2/part/variants.ts b/packages/route-pattern/src/lib2/part/variants.ts index 162e0e67a41..0f372b7ba52 100644 --- a/packages/route-pattern/src/lib2/part/variants.ts +++ b/packages/route-pattern/src/lib2/part/variants.ts @@ -1,9 +1,16 @@ import type { AST } from './ast' -export function variants(ast: AST): Array { - let result: Array = [] +export type Variant = { + key: string + paramIndices: Array +} + +export function variants(ast: AST): Array { + let result: Array = [] - let q: Array<{ index: number; variant: string }> = [{ index: 0, variant: '' }] + let q: Array<{ index: number; variant: Variant }> = [ + { index: 0, variant: { key: '', paramIndices: [] } }, + ] while (q.length > 0) { let { index, variant } = q.pop()! @@ -16,7 +23,7 @@ export function variants(ast: AST): Array { if (token.type === '(') { q.push( { index: index + 1, variant }, // include optional - { index: ast.optionals.get(index)! + 1, variant }, // exclude optional + { index: ast.optionals.get(index)! + 1, variant: structuredClone(variant) }, // exclude optional ) continue } @@ -26,17 +33,24 @@ export function variants(ast: AST): Array { } if (token.type === ':') { - q.push({ index: index + 1, variant: variant + `{:${token.nameIndex}}` }) + variant.key += '{:}' + variant.paramIndices.push(token.nameIndex) + q.push({ index: index + 1, variant }) continue } if (token.type === '*') { - q.push({ index: index + 1, variant: variant + `{*${token.nameIndex ?? ''}}` }) + variant.key += '{*}' + if (token.nameIndex) { + variant.paramIndices.push(token.nameIndex) + } + q.push({ index: index + 1, variant }) continue } if (token.type === 'text') { - q.push({ index: index + 1, variant: variant + token.text }) + variant.key += token.text + q.push({ index: index + 1, variant }) continue } From 64e94766328d6dbef84679e0a1b23e17233a7876 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 11 Dec 2025 11:32:55 -0500 Subject: [PATCH 10/54] part api --- packages/route-pattern/src/lib2/part/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/route-pattern/src/lib2/part/index.ts b/packages/route-pattern/src/lib2/part/index.ts index 9dad1da976b..236a4ae93de 100644 --- a/packages/route-pattern/src/lib2/part/index.ts +++ b/packages/route-pattern/src/lib2/part/index.ts @@ -1,2 +1,4 @@ export type { AST } from './ast.ts' export { parse } from './parse.ts' +export { toRegExp } from './to-regexp.ts' +export { variants, type Variant } from './variants.ts' From 2969df15184524ec7ae13d2a324ee338b4ff6d1d Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 11 Dec 2025 12:03:06 -0500 Subject: [PATCH 11/54] vitest --- .../route-pattern/src/lib2/part/parse.test.ts | 5 +-- .../src/lib2/part/to-regexp.test.ts | 37 +++++++++---------- .../src/lib2/part/variants.test.ts | 5 +-- .../src/lib2/route-pattern.test.ts | 9 ++--- 4 files changed, 26 insertions(+), 30 deletions(-) diff --git a/packages/route-pattern/src/lib2/part/parse.test.ts b/packages/route-pattern/src/lib2/part/parse.test.ts index 35ee9f04730..2da5257edde 100644 --- a/packages/route-pattern/src/lib2/part/parse.test.ts +++ b/packages/route-pattern/src/lib2/part/parse.test.ts @@ -1,5 +1,4 @@ -import * as assert from 'node:assert/strict' -import { describe, it } from 'node:test' +import { describe, expect, it } from 'vitest' import { parse } from './parse.ts' @@ -7,7 +6,7 @@ describe('parse', () => { it('creates an AST', () => { let source = 'api/(v:major(.:minor)/)run' let ast = parse(source) - assert.deepEqual(ast, { + expect(ast).toEqual({ tokens: [ { type: 'text', text: 'api/' }, { type: '(' }, diff --git a/packages/route-pattern/src/lib2/part/to-regexp.test.ts b/packages/route-pattern/src/lib2/part/to-regexp.test.ts index fbb649fe83c..a6cfeca4075 100644 --- a/packages/route-pattern/src/lib2/part/to-regexp.test.ts +++ b/packages/route-pattern/src/lib2/part/to-regexp.test.ts @@ -1,5 +1,4 @@ -import * as assert from 'node:assert/strict' -import { describe, it } from 'node:test' +import { describe, expect, it } from 'vitest' import { parse } from './parse.ts' import { toRegExp } from './to-regexp.ts' @@ -12,13 +11,13 @@ describe('toRegExp', () => { let result = toRegExp(ast, paramValueRE) // The regex should match the full pattern - assert.match('api/v1.2/run', result) - assert.match('api/v1/run', result) - assert.match('api/run', result) + expect('api/v1.2/run').toMatch(result) + expect('api/v1/run').toMatch(result) + expect('api/run').toMatch(result) // Should not match patterns that don't fit - assert.doesNotMatch('api/', result) - assert.doesNotMatch('api/v1.2/walk', result) + expect('api/').not.toMatch(result) + expect('api/v1.2/walk').not.toMatch(result) }) it('handles static text', () => { @@ -27,8 +26,8 @@ describe('toRegExp', () => { let paramValueRE = /[^/]+/ let result = toRegExp(ast, paramValueRE) - assert.match('hello/world', result) - assert.doesNotMatch('hello/there', result) + expect('hello/world').toMatch(result) + expect('hello/there').not.toMatch(result) }) it('handles parameters', () => { @@ -37,10 +36,10 @@ describe('toRegExp', () => { let paramValueRE = /[^/]+/ let result = toRegExp(ast, paramValueRE) - assert.match('users/123', result) - assert.match('users/abc', result) - assert.doesNotMatch('users/', result) - assert.doesNotMatch('users/123/posts', result) + expect('users/123').toMatch(result) + expect('users/abc').toMatch(result) + expect('users/').not.toMatch(result) + expect('users/123/posts').not.toMatch(result) }) it('handles wildcards', () => { @@ -49,9 +48,9 @@ describe('toRegExp', () => { let paramValueRE = /[^/]+/ let result = toRegExp(ast, paramValueRE) - assert.match('files/anything', result) - assert.match('files/path/to/file', result) - assert.match('files/', result) + expect('files/anything').toMatch(result) + expect('files/path/to/file').toMatch(result) + expect('files/').toMatch(result) }) it('escapes special regex characters in static text', () => { @@ -61,8 +60,8 @@ describe('toRegExp', () => { let result = toRegExp(ast, paramValueRE) // The literal '.' and parentheses should be escaped - assert.match('api/v1.0/users/123/notes', result) - assert.match('api/v1.0/users/123/', result) - assert.doesNotMatch('api/v1X0/users/123/notes', result) // '.' shouldn't match any char + expect('api/v1.0/users/123/notes').toMatch(result) + expect('api/v1.0/users/123/').toMatch(result) + expect('api/v1X0/users/123/notes').not.toMatch(result) // '.' shouldn't match any char }) }) diff --git a/packages/route-pattern/src/lib2/part/variants.test.ts b/packages/route-pattern/src/lib2/part/variants.test.ts index 8b538cc1fb6..f7226ae9a8f 100644 --- a/packages/route-pattern/src/lib2/part/variants.test.ts +++ b/packages/route-pattern/src/lib2/part/variants.test.ts @@ -1,5 +1,4 @@ -import * as assert from 'node:assert/strict' -import { describe, it } from 'node:test' +import { describe, expect, it } from 'vitest' import { parse } from './parse.ts' import { variants } from './variants.ts' @@ -8,7 +7,7 @@ describe('variants', () => { it('returns all possible combinations of optionals', () => { let source = 'api/(v:major(.:minor)/)run' let ast = parse(source) - assert.deepEqual(variants(ast), [ + expect(variants(ast)).toEqual([ { key: 'api/run', paramIndices: [] }, { key: 'api/v{:}/run', paramIndices: [0] }, { key: 'api/v{:}.{:}/run', paramIndices: [0, 1] }, diff --git a/packages/route-pattern/src/lib2/route-pattern.test.ts b/packages/route-pattern/src/lib2/route-pattern.test.ts index 0b99a336216..163b6caa44c 100644 --- a/packages/route-pattern/src/lib2/route-pattern.test.ts +++ b/packages/route-pattern/src/lib2/route-pattern.test.ts @@ -1,12 +1,11 @@ -import * as assert from 'node:assert/strict' -import { describe, it } from 'node:test' +import { describe, expect, it } from 'vitest' import { parse } from './route-pattern.ts' describe('parse', () => { it('parses a simple pathname', () => { let ast = parse('users/:id') - assert.deepEqual(ast, { + expect(ast).toEqual({ protocol: undefined, hostname: undefined, port: undefined, @@ -24,7 +23,7 @@ describe('parse', () => { it('parses a full URL pattern', () => { let ast = parse('https://example.com/users/:id') - assert.deepEqual(ast, { + expect(ast).toEqual({ protocol: { tokens: [{ type: 'text', text: 'https' }], paramNames: [], @@ -50,7 +49,7 @@ describe('parse', () => { it('parses protocol and pathname without hostname', () => { let ast = parse('file:///path/to/file') - assert.deepEqual(ast, { + expect(ast).toEqual({ protocol: { tokens: [{ type: 'text', text: 'file' }], paramNames: [], From 81601b3efa2eff00d7a6deca6aa20977cd16fecb Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 11 Dec 2025 12:08:08 -0500 Subject: [PATCH 12/54] route-pattern variants --- .../route-pattern/src/lib2/route-pattern.ts | 2 +- .../route-pattern/src/lib2/variants.test.ts | 38 +++++++++++++++++++ packages/route-pattern/src/lib2/variants.ts | 27 +++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 packages/route-pattern/src/lib2/variants.test.ts create mode 100644 packages/route-pattern/src/lib2/variants.ts diff --git a/packages/route-pattern/src/lib2/route-pattern.ts b/packages/route-pattern/src/lib2/route-pattern.ts index 721918c0695..306ca3093cc 100644 --- a/packages/route-pattern/src/lib2/route-pattern.ts +++ b/packages/route-pattern/src/lib2/route-pattern.ts @@ -1,7 +1,7 @@ import { split } from './split.ts' import * as Part from './part/index.ts' -type AST = { +export type AST = { protocol: Part.AST | undefined hostname: Part.AST | undefined port: string | undefined diff --git a/packages/route-pattern/src/lib2/variants.test.ts b/packages/route-pattern/src/lib2/variants.test.ts new file mode 100644 index 00000000000..e5c03f9ea12 --- /dev/null +++ b/packages/route-pattern/src/lib2/variants.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' + +import { parse } from './route-pattern.ts' +import { variants } from './variants.ts' + +describe('variants', () => { + it('returns all possible combinations of optionals for a simple pathname', () => { + let source = 'api/(v:major(.:minor)/)run' + let ast = parse(source) + let results = Array.from(variants(ast)) + expect(results).toEqual([ + { + key: [[], [], ['api', 'run']], + paramIndices: [[], [], []], + }, + { + key: [[], [], ['api', 'v{:}', 'run']], + paramIndices: [[], [], [0]], + }, + { + key: [[], [], ['api', 'v{:}.{:}', 'run']], + paramIndices: [[], [], [0, 1]], + }, + ]) + }) + + it('returns variants for a full URL pattern', () => { + let source = 'https://example.com/users/:id' + let ast = parse(source) + let results = Array.from(variants(ast)) + expect(results).toEqual([ + { + key: [['https'], ['example', 'com'], ['users', '{:}']], + paramIndices: [[], [], [0]], + }, + ]) + }) +}) diff --git a/packages/route-pattern/src/lib2/variants.ts b/packages/route-pattern/src/lib2/variants.ts new file mode 100644 index 00000000000..30c38f7ed87 --- /dev/null +++ b/packages/route-pattern/src/lib2/variants.ts @@ -0,0 +1,27 @@ +import type { AST } from './route-pattern.ts' +import * as Part from './part/index.ts' + +export function* variants(pattern: AST) { + let protocols = pattern.protocol ? Part.variants(pattern.protocol) : undefined + let hostnames = pattern.hostname ? Part.variants(pattern.hostname) : undefined + let pathnames = pattern.pathname ? Part.variants(pattern.pathname) : undefined + + for (let protocol of protocols ?? [null]) { + for (let hostname of hostnames ?? [null]) { + for (let pathname of pathnames ?? [null]) { + yield { + key: [ + protocol ? [protocol.key] : [], + hostname?.key.split('.').reverse() ?? [], + pathname?.key.split('/') ?? [], + ], + paramIndices: [ + protocol?.paramIndices ?? [], + hostname?.paramIndices ?? [], + pathname?.paramIndices ?? [], + ], + } + } + } + } +} From 102f2e19ab2fe38b5f95da31a1d1abba56081d1b Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 12 Dec 2025 14:44:36 -0500 Subject: [PATCH 13/54] route-pattern namespace --- packages/route-pattern/src/lib2/route-pattern/ast.ts | 9 +++++++++ .../route-pattern/src/lib2/route-pattern/index.ts | 3 +++ .../parse.test.ts} | 2 +- .../lib2/{route-pattern.ts => route-pattern/parse.ts} | 11 ++--------- .../src/lib2/{ => route-pattern}/split.ts | 2 +- .../src/lib2/{ => route-pattern}/variants.test.ts | 2 +- .../src/lib2/{ => route-pattern}/variants.ts | 4 ++-- 7 files changed, 19 insertions(+), 14 deletions(-) create mode 100644 packages/route-pattern/src/lib2/route-pattern/ast.ts create mode 100644 packages/route-pattern/src/lib2/route-pattern/index.ts rename packages/route-pattern/src/lib2/{route-pattern.test.ts => route-pattern/parse.test.ts} (97%) rename packages/route-pattern/src/lib2/{route-pattern.ts => route-pattern/parse.ts} (77%) rename packages/route-pattern/src/lib2/{ => route-pattern}/split.ts (98%) rename packages/route-pattern/src/lib2/{ => route-pattern}/variants.test.ts (95%) rename packages/route-pattern/src/lib2/{ => route-pattern}/variants.ts (90%) diff --git a/packages/route-pattern/src/lib2/route-pattern/ast.ts b/packages/route-pattern/src/lib2/route-pattern/ast.ts new file mode 100644 index 00000000000..d3874cdede0 --- /dev/null +++ b/packages/route-pattern/src/lib2/route-pattern/ast.ts @@ -0,0 +1,9 @@ +import type * as Part from '../part/index.ts' + +export type AST = { + protocol: Part.AST | undefined + hostname: Part.AST | undefined + port: string | undefined + pathname: Part.AST | undefined + search: string | undefined // todo +} diff --git a/packages/route-pattern/src/lib2/route-pattern/index.ts b/packages/route-pattern/src/lib2/route-pattern/index.ts new file mode 100644 index 00000000000..432ab1c1ff2 --- /dev/null +++ b/packages/route-pattern/src/lib2/route-pattern/index.ts @@ -0,0 +1,3 @@ +export type { AST } from './ast.ts' +export { parse } from './parse.ts' +export { variants } from './variants.ts' diff --git a/packages/route-pattern/src/lib2/route-pattern.test.ts b/packages/route-pattern/src/lib2/route-pattern/parse.test.ts similarity index 97% rename from packages/route-pattern/src/lib2/route-pattern.test.ts rename to packages/route-pattern/src/lib2/route-pattern/parse.test.ts index 163b6caa44c..14aef21614a 100644 --- a/packages/route-pattern/src/lib2/route-pattern.test.ts +++ b/packages/route-pattern/src/lib2/route-pattern/parse.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { parse } from './route-pattern.ts' +import { parse } from './parse.ts' describe('parse', () => { it('parses a simple pathname', () => { diff --git a/packages/route-pattern/src/lib2/route-pattern.ts b/packages/route-pattern/src/lib2/route-pattern/parse.ts similarity index 77% rename from packages/route-pattern/src/lib2/route-pattern.ts rename to packages/route-pattern/src/lib2/route-pattern/parse.ts index 306ca3093cc..8aafe236c75 100644 --- a/packages/route-pattern/src/lib2/route-pattern.ts +++ b/packages/route-pattern/src/lib2/route-pattern/parse.ts @@ -1,13 +1,6 @@ +import type { AST } from './ast.ts' import { split } from './split.ts' -import * as Part from './part/index.ts' - -export type AST = { - protocol: Part.AST | undefined - hostname: Part.AST | undefined - port: string | undefined - pathname: Part.AST | undefined - search: string | undefined // todo -} +import * as Part from '../part/index.ts' export function parse(source: string): AST { let ast: AST = { diff --git a/packages/route-pattern/src/lib2/split.ts b/packages/route-pattern/src/lib2/route-pattern/split.ts similarity index 98% rename from packages/route-pattern/src/lib2/split.ts rename to packages/route-pattern/src/lib2/route-pattern/split.ts index 917cf0ab962..996502cab09 100644 --- a/packages/route-pattern/src/lib2/split.ts +++ b/packages/route-pattern/src/lib2/route-pattern/split.ts @@ -1,4 +1,4 @@ -import type { Span } from './span' +import type { Span } from '../span' export interface SplitResult { protocol: Span | undefined diff --git a/packages/route-pattern/src/lib2/variants.test.ts b/packages/route-pattern/src/lib2/route-pattern/variants.test.ts similarity index 95% rename from packages/route-pattern/src/lib2/variants.test.ts rename to packages/route-pattern/src/lib2/route-pattern/variants.test.ts index e5c03f9ea12..5d11754a1a5 100644 --- a/packages/route-pattern/src/lib2/variants.test.ts +++ b/packages/route-pattern/src/lib2/route-pattern/variants.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { parse } from './route-pattern.ts' +import { parse } from './parse.ts' import { variants } from './variants.ts' describe('variants', () => { diff --git a/packages/route-pattern/src/lib2/variants.ts b/packages/route-pattern/src/lib2/route-pattern/variants.ts similarity index 90% rename from packages/route-pattern/src/lib2/variants.ts rename to packages/route-pattern/src/lib2/route-pattern/variants.ts index 30c38f7ed87..7e1d8e9b19c 100644 --- a/packages/route-pattern/src/lib2/variants.ts +++ b/packages/route-pattern/src/lib2/route-pattern/variants.ts @@ -1,5 +1,5 @@ -import type { AST } from './route-pattern.ts' -import * as Part from './part/index.ts' +import type { AST } from './ast.ts' +import * as Part from '../part/index.ts' export function* variants(pattern: AST) { let protocols = pattern.protocol ? Part.variants(pattern.protocol) : undefined From 52ec2c079720f780dbe2b52ecc4b6278ccb1cdf6 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 12 Dec 2025 14:49:44 -0500 Subject: [PATCH 14/54] trie --- packages/route-pattern/src/lib2/trie.test.ts | 277 +++++++++++++++++++ packages/route-pattern/src/lib2/trie.ts | 267 ++++++++++++++++++ 2 files changed, 544 insertions(+) create mode 100644 packages/route-pattern/src/lib2/trie.test.ts create mode 100644 packages/route-pattern/src/lib2/trie.ts diff --git a/packages/route-pattern/src/lib2/trie.test.ts b/packages/route-pattern/src/lib2/trie.test.ts new file mode 100644 index 00000000000..7a7efead9df --- /dev/null +++ b/packages/route-pattern/src/lib2/trie.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, it } from 'vitest' + +import { parse } from './route-pattern/parse.ts' +import { create } from './trie.ts' + +describe('trie', () => { + describe('create', () => { + it('creates a trie with root node', () => { + let trie = create() + expect(trie.root).toBeTruthy() + expect(trie.root.static).toEqual({}) + expect(trie.root.variable).toEqual(new Map()) + expect(trie.root.next).toBe(undefined) + expect(trie.root.match).toBe(undefined) + }) + }) + + describe('add', () => { + it('adds a simple static pathname pattern', () => { + let trie = create() + let pattern = parse('users/list') + trie._add(pattern) + + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) + expect(trie.root.next?.next?.static['users']).toBeTruthy() + expect(trie.root.next?.next?.static['users']?.static['list']).toBeTruthy() + }) + + it('adds a pattern with dynamic segment', () => { + let trie = create() + let pattern = parse('users/:id') + trie._add(pattern) + + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) + expect(trie.root.next?.next?.static['users']).toBeTruthy() + expect(trie.root.next?.next?.static['users']?.variable.has('{:}')).toBeTruthy() + }) + + it('adds a full URL pattern', () => { + let trie = create() + let pattern = parse('https://example.com/users/:id') + trie._add(pattern) + + // Protocol level + expect(trie.root.static['https']).toBeTruthy() + // Should have a next pointer to move to hostname level + expect(trie.root.static['https']?.next).toBeTruthy() + // Hostname level + let hostnameLevel = trie.root.static['https']?.next + expect(hostnameLevel?.static['example']).toBeTruthy() + expect(hostnameLevel?.static['example']?.static['com']).toBeTruthy() + // Should have a next pointer to move to pathname level + expect(hostnameLevel?.static['example']?.static['com']?.next).toBeTruthy() + }) + + it('adds patterns with optional segments', () => { + let trie = create() + let pattern = parse('api/(v:major(.:minor)/)run') + trie._add(pattern) + + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) + let pathnameLevel = trie.root.next?.next + + // Should have multiple variants added to the trie + // Variant 1: api/run + expect(pathnameLevel?.static['api']).toBeTruthy() + expect(pathnameLevel?.static['api']?.static['run']).toBeTruthy() + + // Variant 2: api/v{:}/run + expect(pathnameLevel?.static['api']?.variable.get('v{:}')).toBeTruthy() + + // Variant 3: api/v{:}.{:}/run + expect(pathnameLevel?.static['api']?.variable.get('v{:}.{:}')).toBeTruthy() + }) + + it('adds multiple patterns with shared prefixes', () => { + let trie = create() + let pattern1 = parse('users/:id') + let pattern2 = parse('users/admin') + let pattern3 = parse('posts/:id') + + trie._add(pattern1) + trie._add(pattern2) + trie._add(pattern3) + + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) + let pathnameLevel = trie.root.next?.next + + // Users branch + expect(pathnameLevel?.static['users']).toBeTruthy() + expect(pathnameLevel?.static['users']?.static['admin']).toBeTruthy() + expect(pathnameLevel?.static['users']?.variable.has('{:}')).toBeTruthy() + + // Posts branch + expect(pathnameLevel?.static['posts']).toBeTruthy() + expect(pathnameLevel?.static['posts']?.variable.has('{:}')).toBeTruthy() + }) + + it('adds a pattern with a wildcard', () => { + let trie = create() + let pattern = parse('files/*path') + trie._add(pattern) + + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) + let pathnameLevel = trie.root.next?.next + + // Should have static 'files' segment + expect(pathnameLevel?.static['files']).toBeTruthy() + // Wildcard should be stored in the wildcard map + expect(pathnameLevel?.static['files']?.wildcard.has('{*}')).toBeTruthy() + }) + + it('adds a pattern with wildcard after static prefix', () => { + let trie = create() + let pattern = parse('assets/images/*file') + trie._add(pattern) + + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) + let pathnameLevel = trie.root.next?.next + + expect(pathnameLevel?.static['assets']).toBeTruthy() + expect(pathnameLevel?.static['assets']?.static['images']).toBeTruthy() + expect(pathnameLevel?.static['assets']?.static['images']?.wildcard.has('{*}')).toBeTruthy() + }) + + it('adds a pattern with inline wildcard', () => { + let trie = create() + let pattern = parse('files/prefix-*rest') + trie._add(pattern) + + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) + let pathnameLevel = trie.root.next?.next + + expect(pathnameLevel?.static['files']).toBeTruthy() + // Wildcard key should include the prefix + expect(pathnameLevel?.static['files']?.wildcard.has('prefix-{*}')).toBeTruthy() + }) + + it('adds a pattern with anonymous wildcard', () => { + let trie = create() + let pattern = parse('api/*') + trie._add(pattern) + + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) + let pathnameLevel = trie.root.next?.next + + expect(pathnameLevel?.static['api']).toBeTruthy() + expect(pathnameLevel?.static['api']?.wildcard.has('{*}')).toBeTruthy() + }) + + it('adds a pattern with wildcard followed by static segment', () => { + let trie = create() + let pattern = parse('files/*path/details') + trie._add(pattern) + + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) + let pathnameLevel = trie.root.next?.next + + expect(pathnameLevel?.static['files']).toBeTruthy() + // Wildcard key should include the segments after the wildcard + expect(pathnameLevel?.static['files']?.wildcard.has('{*}/details')).toBeTruthy() + }) + + it('adds a pattern with wildcard followed by multiple segments', () => { + let trie = create() + let pattern = parse('files/*path/foo/bar') + trie._add(pattern) + + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) + let pathnameLevel = trie.root.next?.next + + expect(pathnameLevel?.static['files']).toBeTruthy() + // Wildcard key should include all segments after the wildcard + expect(pathnameLevel?.static['files']?.wildcard.has('{*}/foo/bar')).toBeTruthy() + }) + + it('adds a pattern with wildcard followed by dynamic segment', () => { + let trie = create() + let pattern = parse('files/*path/:id') + trie._add(pattern) + + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) + let pathnameLevel = trie.root.next?.next + + expect(pathnameLevel?.static['files']).toBeTruthy() + // Wildcard key should include the dynamic segment after the wildcard + expect(pathnameLevel?.static['files']?.wildcard.has('{*}/{:}')).toBeTruthy() + }) + }) + + describe('match', () => { + it('matches a simple static pathname pattern', () => { + let trie = create() + let pattern = parse('users/list') + trie._add(pattern) + + let matches = [...trie._match(new URL('https://example.com/users/list'))] + expect(matches.length).toBe(1) + expect(matches[0]?.pattern).toBe(pattern) + }) + + it('returns no matches for non-matching URL', () => { + let trie = create() + let pattern = parse('users/list') + trie._add(pattern) + + let matches = [...trie._match(new URL('https://example.com/posts/list'))] + expect(matches.length).toBe(0) + }) + + it('matches a pattern with dynamic segment', () => { + let trie = create() + let pattern = parse('users/:id') + trie._add(pattern) + + let matches = [...trie._match(new URL('https://example.com/users/123'))] + expect(matches.length).toBe(1) + expect(matches[0]?.pattern).toBe(pattern) + expect(matches[0]?.params).toEqual({ id: '123' }) + }) + + it('matches a full URL pattern', () => { + let trie = create() + let pattern = parse('https://example.com/users/:id') + trie._add(pattern) + + let matches = [...trie._match(new URL('https://example.com/users/456'))] + expect(matches.length).toBe(1) + expect(matches[0]?.pattern).toBe(pattern) + }) + + it('does not match wrong protocol', () => { + let trie = create() + let pattern = parse('https://example.com/users') + trie._add(pattern) + + let matches = [...trie._match(new URL('http://example.com/users'))] + expect(matches.length).toBe(0) + }) + + it('does not match wrong hostname', () => { + let trie = create() + let pattern = parse('https://example.com/users') + trie._add(pattern) + + let matches = [...trie._match(new URL('https://other.com/users'))] + expect(matches.length).toBe(0) + }) + + it('matches a pattern with wildcard', () => { + let trie = create() + let pattern = parse('files/*path') + trie._add(pattern) + + let matches = [...trie._match(new URL('https://example.com/files/a/b/c'))] + expect(matches.length).toBe(1) + expect(matches[0]?.pattern).toBe(pattern) + }) + + it('matches multiple patterns with shared prefix', () => { + let trie = create() + let pattern1 = parse('users/:id') + let pattern2 = parse('users/admin') + trie._add(pattern1) + trie._add(pattern2) + + // Should match the static pattern + let matches1 = [...trie._match(new URL('https://example.com/users/admin'))] + expect(matches1.length).toBe(2) // Both patterns match + + // Should only match the dynamic pattern + let matches2 = [...trie._match(new URL('https://example.com/users/123'))] + expect(matches2.length).toBe(1) + expect(matches2[0]?.pattern).toBe(pattern1) + }) + }) +}) diff --git a/packages/route-pattern/src/lib2/trie.ts b/packages/route-pattern/src/lib2/trie.ts new file mode 100644 index 00000000000..7515c22a952 --- /dev/null +++ b/packages/route-pattern/src/lib2/trie.ts @@ -0,0 +1,267 @@ +import * as RoutePattern from './route-pattern/index.ts' +import * as RE from './regexp.ts' + +type Trie = { + static: Record + variable: Map + wildcard: Map + next: Trie | undefined + match: InternalMatch | undefined +} + +type InternalMatch = { + order: number + pattern: RoutePattern.AST + paramIndices: Array> +} + +type Match = { + order: number + pattern: RoutePattern.AST + params: Record +} + +type MatchState = { index: [number, number]; trie: Trie; paramValues: Array> } + +function node(): Trie { + return { + static: {}, + variable: new Map(), + wildcard: new Map(), + next: undefined, + match: undefined, + } +} + +export function create() { + let root = node() + let size = 0 + return { + root, + get size() { + return size + }, + add(pattern: string) { + let x = RoutePattern.parse(pattern) + this._add(x) + }, + _add(pattern: RoutePattern.AST) { + let order = size + size += 1 + + for (let variant of RoutePattern.variants(pattern)) { + let match: InternalMatch = { + order, + pattern, + paramIndices: variant.paramIndices, + } + + let trie = root + let index = [0, 0] + while (true) { + if (index[0] === variant.key.length) { + trie.match = match + break + } + + let part = variant.key[index[0]] + if (index[1] >= part.length) { + if (!trie.next) { + trie.next = node() + } + trie = trie.next + index[0] += 1 + index[1] = 0 + continue + } + + let segment = part[index[1]] + + let hasWildcard = segment.includes('{*}') + if (hasWildcard) { + let segments = part.slice(index[1]) + let key = segments.join( + // prettier-ignore + index[0] === 1 ? '.' : + index[0] === 2 ? '/' : + '', + ) + let regexp = keyToRegExp(key) + let next = trie.next + if (!next) { + next = node() + trie.next = next + } + trie.wildcard.set(key, [regexp, next]) + index[0] += 1 + index[1] = 0 + trie = next + continue + } + + let hasVariable = segment.includes('{:}') + if (hasVariable) { + let next = trie.variable.get(segment) + if (!next) { + next = [keyToRegExp(segment), node()] + trie.variable.set(segment, next) + } + trie = next[1] + index[1] += 1 + continue + } + + let next = trie.static[segment] + if (!next) { + next = node() + trie.static[segment] = next + } + trie = next + index[1] += 1 + } + } + }, + match(url: URL) { + return this.matchByOrder(url) + }, + matchAny(url: URL): Match | null { + for (let match of this._match(url)) { + return match + } + return null + }, + matchByOrder(url: URL) { + let best: Match | null = null + for (let match of this._match(url)) { + if (match.order === 0) return match + if (best === null) { + best = match + continue + } + if (match.order < best.order) { + best = match + continue + } + } + return best + }, + *_match(url: URL): Generator { + let protocol = url.protocol.slice(0, -1) + let hostname = url.hostname.split('.').reverse() + let pathname = url.pathname.slice(1).split('/') + let query = [[protocol], hostname, pathname] + + let q: Array = [{ index: [0, 0], trie: root, paramValues: [[], [], []] }] + while (q.length > 0) { + let state = q.pop()! + + if (state.index[0] === query.length) { + if (state.trie.match) { + let { pattern } = state.trie.match + yield { + order: state.trie.match.order, + pattern, + params: toParams(state), + } + } + continue + } + + let part = query[state.index[0]] + if (state.index[1] === part.length) { + if (state.trie.next) { + state.index[0] += 1 + state.index[1] = 0 + state.trie = state.trie.next + q.push(state) + } + continue + } + + let segment = part[state.index[1]] + + let staticMatch = state.trie.static[segment] + if (staticMatch) { + q.push({ + index: [state.index[0], state.index[1] + 1], + trie: staticMatch, + paramValues: state.paramValues, + }) + } + + for (let [regexp, trie] of state.trie.variable.values()) { + let match = regexp.exec(segment) + if (match) { + let paramValues = structuredClone(state.paramValues) + paramValues[state.index[0]].push(...match.slice(1)) + q.push({ + index: [state.index[0], state.index[1] + 1], + trie, + paramValues, + }) + } + } + + for (let [regexp, trie] of state.trie.wildcard.values()) { + let key = part.slice(state.index[1]).join( + // prettier-ignore + state.index[0] === 1 ? '.' : + state.index[0] === 2 ? '/' : + '', + ) + let match = regexp.exec(key) + if (match) { + let paramValues = structuredClone(state.paramValues) + paramValues[state.index[0]].push(...match.slice(1)) + q.push({ + index: [state.index[0] + 1, 0], + trie, + paramValues, + }) + } + } + + if (state.trie.next) { + state.index[0] += 1 + state.index[1] = 0 + state.trie = state.trie.next + q.push(state) + } + } + }, + } +} + +function toParams(state: MatchState) { + let { pattern, paramIndices } = state.trie.match! + let { paramValues } = state + let names = [ + pattern.protocol?.paramNames ?? [], + pattern.hostname?.paramNames ?? [], + pattern.pathname?.paramNames ?? [], + ] + let params: Record = {} + names.forEach((part) => + part.forEach((name) => { + params[name] = undefined + }), + ) + + for (let partIndex = 0; partIndex < paramIndices.length; partIndex++) { + let part = paramIndices[partIndex] + for (let i = 0; i < part.length; i++) { + let name = names[partIndex][i] + let value = paramValues[partIndex][i] + params[name] = value + } + } + return params +} + +function keyToRegExp(key: string): RegExp { + let source = key + .split(/\{:\}|\{\*\}/) + .map(RE.escape) + .join('([^/]*)') + return new RegExp(`^${source}$`) +} From 844cb48c25acd2538516af057d1e9fb8240a0ee4 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 12 Dec 2025 17:18:19 -0500 Subject: [PATCH 15/54] wip: matchers namespace --- .../src/lib2/matchers/matcher.ts | 5 + .../src/lib2/matchers/trie.test.ts | 288 ++++++++++++++++++ .../route-pattern/src/lib2/matchers/trie.ts | 177 +++++++++++ 3 files changed, 470 insertions(+) create mode 100644 packages/route-pattern/src/lib2/matchers/matcher.ts create mode 100644 packages/route-pattern/src/lib2/matchers/trie.test.ts create mode 100644 packages/route-pattern/src/lib2/matchers/trie.ts diff --git a/packages/route-pattern/src/lib2/matchers/matcher.ts b/packages/route-pattern/src/lib2/matchers/matcher.ts new file mode 100644 index 00000000000..2a7b146e5ef --- /dev/null +++ b/packages/route-pattern/src/lib2/matchers/matcher.ts @@ -0,0 +1,5 @@ +export type Matcher = { + add: () => void + match: () => void + size: number +} diff --git a/packages/route-pattern/src/lib2/matchers/trie.test.ts b/packages/route-pattern/src/lib2/matchers/trie.test.ts new file mode 100644 index 00000000000..acefecd562d --- /dev/null +++ b/packages/route-pattern/src/lib2/matchers/trie.test.ts @@ -0,0 +1,288 @@ +import { describe, expect, it } from 'vitest' + +import { parse } from '../route-pattern/parse.ts' +import type * as RoutePattern from '../route-pattern/index.ts' +import { create, insert, search, type Match } from './trie.ts' + +function createMatch(pattern: RoutePattern.AST): Match { + return { + pattern, + paramIndices: [[], [], []], + } +} + +function searchAll(trie: ReturnType, url: URL): Match[] { + return [...search(trie, url)] +} + +describe('trie', () => { + describe('create', () => { + it('creates a trie with empty nodes', () => { + let trie = create() + expect(trie.static).toEqual({}) + expect(trie.variable).toEqual(new Map()) + expect(trie.wildcard).toEqual(new Map()) + expect(trie.next).toBe(undefined) + expect(trie.match).toBe(undefined) + }) + }) + + describe('insert', () => { + it('inserts a simple static pathname pattern', () => { + let trie = create() + let pattern = parse('users/list') + insert(trie, createMatch(pattern)) + + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) + expect(trie.next?.next?.static['users']).toBeTruthy() + expect(trie.next?.next?.static['users']?.static['list']).toBeTruthy() + }) + + it('inserts a pattern with dynamic segment', () => { + let trie = create() + let pattern = parse('users/:id') + insert(trie, createMatch(pattern)) + + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) + expect(trie.next?.next?.static['users']).toBeTruthy() + expect(trie.next?.next?.static['users']?.variable.has('{:}')).toBeTruthy() + }) + + it('inserts a full URL pattern', () => { + let trie = create() + let pattern = parse('https://example.com/users/:id') + insert(trie, createMatch(pattern)) + + // Protocol level + expect(trie.static['https']).toBeTruthy() + // Should have a next pointer to move to hostname level + expect(trie.static['https']?.next).toBeTruthy() + // Hostname level (reversed: com, example) + let hostnameLevel = trie.static['https']?.next + expect(hostnameLevel?.static['com']).toBeTruthy() + expect(hostnameLevel?.static['com']?.static['example']).toBeTruthy() + // Should have a next pointer to move to pathname level + expect(hostnameLevel?.static['com']?.static['example']?.next).toBeTruthy() + }) + + it('inserts patterns with optional segments', () => { + let trie = create() + let pattern = parse('api/(v:major(.:minor)/)run') + insert(trie, createMatch(pattern)) + + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) + let pathnameLevel = trie.next?.next + + // Should have multiple variants added to the trie + // Variant 1: api/run + expect(pathnameLevel?.static['api']).toBeTruthy() + expect(pathnameLevel?.static['api']?.static['run']).toBeTruthy() + + // Variant 2: api/v{:}/run + expect(pathnameLevel?.static['api']?.variable.get('v{:}')).toBeTruthy() + + // Variant 3: api/v{:}.{:}/run + expect(pathnameLevel?.static['api']?.variable.get('v{:}.{:}')).toBeTruthy() + }) + + it('inserts multiple patterns with shared prefixes', () => { + let trie = create() + let pattern1 = parse('users/:id') + let pattern2 = parse('users/admin') + let pattern3 = parse('posts/:id') + + insert(trie, createMatch(pattern1)) + insert(trie, createMatch(pattern2)) + insert(trie, createMatch(pattern3)) + + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) + let pathnameLevel = trie.next?.next + + // Users branch + expect(pathnameLevel?.static['users']).toBeTruthy() + expect(pathnameLevel?.static['users']?.static['admin']).toBeTruthy() + expect(pathnameLevel?.static['users']?.variable.has('{:}')).toBeTruthy() + + // Posts branch + expect(pathnameLevel?.static['posts']).toBeTruthy() + expect(pathnameLevel?.static['posts']?.variable.has('{:}')).toBeTruthy() + }) + + it('inserts a pattern with a wildcard', () => { + let trie = create() + let pattern = parse('files/*path') + insert(trie, createMatch(pattern)) + + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) + let pathnameLevel = trie.next?.next + + // Should have static 'files' segment + expect(pathnameLevel?.static['files']).toBeTruthy() + // Wildcard should be stored in the wildcard map + expect(pathnameLevel?.static['files']?.wildcard.has('{*}')).toBeTruthy() + }) + + it('inserts a pattern with wildcard after static prefix', () => { + let trie = create() + let pattern = parse('assets/images/*file') + insert(trie, createMatch(pattern)) + + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) + let pathnameLevel = trie.next?.next + + expect(pathnameLevel?.static['assets']).toBeTruthy() + expect(pathnameLevel?.static['assets']?.static['images']).toBeTruthy() + expect(pathnameLevel?.static['assets']?.static['images']?.wildcard.has('{*}')).toBeTruthy() + }) + + it('inserts a pattern with inline wildcard', () => { + let trie = create() + let pattern = parse('files/prefix-*rest') + insert(trie, createMatch(pattern)) + + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) + let pathnameLevel = trie.next?.next + + expect(pathnameLevel?.static['files']).toBeTruthy() + // Wildcard key should include the prefix + expect(pathnameLevel?.static['files']?.wildcard.has('prefix-{*}')).toBeTruthy() + }) + + it('inserts a pattern with anonymous wildcard', () => { + let trie = create() + let pattern = parse('api/*') + insert(trie, createMatch(pattern)) + + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) + let pathnameLevel = trie.next?.next + + expect(pathnameLevel?.static['api']).toBeTruthy() + expect(pathnameLevel?.static['api']?.wildcard.has('{*}')).toBeTruthy() + }) + + it('inserts a pattern with wildcard followed by static segment', () => { + let trie = create() + let pattern = parse('files/*path/details') + insert(trie, createMatch(pattern)) + + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) + let pathnameLevel = trie.next?.next + + expect(pathnameLevel?.static['files']).toBeTruthy() + // Wildcard key should include the segments after the wildcard + expect(pathnameLevel?.static['files']?.wildcard.has('{*}/details')).toBeTruthy() + }) + + it('inserts a pattern with wildcard followed by multiple segments', () => { + let trie = create() + let pattern = parse('files/*path/foo/bar') + insert(trie, createMatch(pattern)) + + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) + let pathnameLevel = trie.next?.next + + expect(pathnameLevel?.static['files']).toBeTruthy() + // Wildcard key should include all segments after the wildcard + expect(pathnameLevel?.static['files']?.wildcard.has('{*}/foo/bar')).toBeTruthy() + }) + + it('inserts a pattern with wildcard followed by dynamic segment', () => { + let trie = create() + let pattern = parse('files/*path/:id') + insert(trie, createMatch(pattern)) + + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) + let pathnameLevel = trie.next?.next + + expect(pathnameLevel?.static['files']).toBeTruthy() + // Wildcard key should include the dynamic segment after the wildcard + expect(pathnameLevel?.static['files']?.wildcard.has('{*}/{:}')).toBeTruthy() + }) + }) + + describe('search', () => { + it('matches a simple static pathname pattern', () => { + let trie = create() + let pattern = parse('users/list') + insert(trie, createMatch(pattern)) + + let matches = searchAll(trie, new URL('https://example.com/users/list')) + expect(matches.length).toBe(1) + expect(matches[0]?.pattern).toBe(pattern) + }) + + it('returns no matches for non-matching URL', () => { + let trie = create() + let pattern = parse('users/list') + insert(trie, createMatch(pattern)) + + let matches = searchAll(trie, new URL('https://example.com/posts/list')) + expect(matches.length).toBe(0) + }) + + it('matches a pattern with dynamic segment', () => { + let trie = create() + let pattern = parse('users/:id') + insert(trie, createMatch(pattern)) + + let matches = searchAll(trie, new URL('https://example.com/users/123')) + expect(matches.length).toBe(1) + expect(matches[0]?.pattern).toBe(pattern) + }) + + it('matches a full URL pattern', () => { + let trie = create() + let pattern = parse('https://example.com/users/:id') + insert(trie, createMatch(pattern)) + + let matches = searchAll(trie, new URL('https://example.com/users/456')) + expect(matches.length).toBe(1) + expect(matches[0]?.pattern).toBe(pattern) + }) + + it('does not match wrong protocol', () => { + let trie = create() + let pattern = parse('https://example.com/users') + insert(trie, createMatch(pattern)) + + let matches = searchAll(trie, new URL('http://example.com/users')) + expect(matches.length).toBe(0) + }) + + it('does not match wrong hostname', () => { + let trie = create() + let pattern = parse('https://example.com/users') + insert(trie, createMatch(pattern)) + + let matches = searchAll(trie, new URL('https://other.com/users')) + expect(matches.length).toBe(0) + }) + + it('matches a pattern with wildcard', () => { + let trie = create() + let pattern = parse('files/*path') + insert(trie, createMatch(pattern)) + + let matches = searchAll(trie, new URL('https://example.com/files/a/b/c')) + expect(matches.length).toBe(1) + expect(matches[0]?.pattern).toBe(pattern) + }) + + it('matches multiple patterns with shared prefix', () => { + let trie = create() + let pattern1 = parse('users/:id') + let pattern2 = parse('users/admin') + insert(trie, createMatch(pattern1)) + insert(trie, createMatch(pattern2)) + + // Should match the static pattern + let matches1 = searchAll(trie, new URL('https://example.com/users/admin')) + expect(matches1.length).toBe(2) // Both patterns match + + // Should only match the dynamic pattern + let matches2 = searchAll(trie, new URL('https://example.com/users/123')) + expect(matches2.length).toBe(1) + expect(matches2[0]?.pattern).toBe(pattern1) + }) + }) +}) diff --git a/packages/route-pattern/src/lib2/matchers/trie.ts b/packages/route-pattern/src/lib2/matchers/trie.ts new file mode 100644 index 00000000000..42a10811322 --- /dev/null +++ b/packages/route-pattern/src/lib2/matchers/trie.ts @@ -0,0 +1,177 @@ +import * as RoutePattern from '../route-pattern/index.ts' +import * as RE from '../regexp.ts' + +type Trie = { + static: Record + variable: Map + wildcard: Map + next: Trie | undefined + match: Match | undefined +} + +export type Match = { + pattern: RoutePattern.AST + paramIndices: [protocol: Array, hostname: Array, pathname: Array] +} + +export function create(): Trie { + return { + static: {}, + variable: new Map(), + wildcard: new Map(), + next: undefined, + match: undefined, + } +} + +function keyToRegExp(key: string): RegExp { + let source = key + .split(/\{:\}|\{\*\}/) + .map(RE.escape) + .join('([^/]*)') + return new RegExp(`^${source}$`) +} + +const separators = ['', '.', '/'] as const + +type Index = [partIndex: number, segmentIndex: number] + +// insert(trie, pattern, data: data) +// Data: { pattern, order, component } +export function insert(root: Trie, match: Match) { + for (let variant of RoutePattern.variants(match.pattern)) { + let trie = root + let index: Index = [0, 0] + while (true) { + if (index[0] === variant.key.length) { + trie.match = match + break + } + + let part = variant.key[index[0]] + if (index[1] >= part.length) { + if (!trie.next) trie.next = create() + trie = trie.next + index[0] += 1 + index[1] = 0 + continue + } + + let segment = part[index[1]] + + let hasWildcard = segment.includes('{*}') + if (hasWildcard) { + let segments = part.slice(index[1]) + let key = segments.join(separators[index[0]]) + let regexp = keyToRegExp(key) + if (!trie.next) trie.next = create() + trie.wildcard.set(key, [regexp, trie.next]) + index[0] += 1 + index[1] = 0 + trie = trie.next + continue + } + + let hasVariable = segment.includes('{:}') + if (hasVariable) { + let next = trie.variable.get(segment) + if (!next) { + next = [keyToRegExp(segment), create()] + trie.variable.set(segment, next) + } + trie = next[1] + index[1] += 1 + continue + } + + let next = trie.static[segment] + if (!next) { + next = create() + trie.static[segment] = next + } + trie = next + index[1] += 1 + } + } +} + +type SearchState = { + index: Index + trie: Trie + paramValues: [protocol: Array, hostname: Array, pathname: Array] +} + +export function* search(root: Trie, url: URL): Generator { + let protocol = url.protocol.slice(0, -1) + let hostname = url.hostname.split('.').reverse() + let pathname = url.pathname.slice(1).split('/') + let query = [[protocol], hostname, pathname] + + let stack: Array = [{ index: [0, 0], trie: root, paramValues: [[], [], []] }] + while (stack.length > 0) { + let state = stack.pop()! + + if (state.index[0] === query.length) { + if (state.trie.match) { + yield state.trie.match + } + continue + } + + let part = query[state.index[0]] + if (state.index[1] === part.length) { + if (state.trie.next) { + state.index[0] += 1 + state.index[1] = 0 + state.trie = state.trie.next + stack.push(state) + } + continue + } + + let segment = part[state.index[1]] + + let staticMatch = state.trie.static[segment] + if (staticMatch) { + stack.push({ + index: [state.index[0], state.index[1] + 1], + trie: staticMatch, + paramValues: state.paramValues, + }) + } + + for (let [regexp, trie] of state.trie.variable.values()) { + let match = regexp.exec(segment) + if (match) { + let paramValues = structuredClone(state.paramValues) + paramValues[state.index[0]].push(...match.slice(1)) + stack.push({ + index: [state.index[0], state.index[1] + 1], + trie, + paramValues, + }) + } + } + + for (let [regexp, trie] of state.trie.wildcard.values()) { + let key = part.slice(state.index[1]).join(separators[state.index[0]]) + let match = regexp.exec(key) + if (match) { + let paramValues = structuredClone(state.paramValues) + paramValues[state.index[0]].push(...match.slice(1)) + stack.push({ + index: [state.index[0] + 1, 0], + trie, + paramValues, + }) + } + } + + if (state.trie.next) { + state.index[0] += 1 + state.index[1] = 0 + state.trie = state.trie.next + stack.push(state) + } + } +} From d3dc321fc17e65d8a20eff79958a84611ca29d10 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 12 Dec 2025 22:44:00 -0500 Subject: [PATCH 16/54] trie class + variant param names + bug fixes --- .../src/lib2/matchers/trie.test.ts | 113 ++++--- .../route-pattern/src/lib2/matchers/trie.ts | 296 +++++++++--------- .../route-pattern/src/lib2/part/variants.ts | 8 +- .../src/lib2/route-pattern/variants.ts | 17 +- packages/route-pattern/src/lib2/trie.test.ts | 277 ---------------- packages/route-pattern/src/lib2/trie.ts | 267 ---------------- 6 files changed, 223 insertions(+), 755 deletions(-) delete mode 100644 packages/route-pattern/src/lib2/trie.test.ts delete mode 100644 packages/route-pattern/src/lib2/trie.ts diff --git a/packages/route-pattern/src/lib2/matchers/trie.test.ts b/packages/route-pattern/src/lib2/matchers/trie.test.ts index acefecd562d..685b17be0c6 100644 --- a/packages/route-pattern/src/lib2/matchers/trie.test.ts +++ b/packages/route-pattern/src/lib2/matchers/trie.test.ts @@ -2,23 +2,16 @@ import { describe, expect, it } from 'vitest' import { parse } from '../route-pattern/parse.ts' import type * as RoutePattern from '../route-pattern/index.ts' -import { create, insert, search, type Match } from './trie.ts' +import { Trie } from './trie.ts' -function createMatch(pattern: RoutePattern.AST): Match { - return { - pattern, - paramIndices: [[], [], []], - } -} - -function searchAll(trie: ReturnType, url: URL): Match[] { - return [...search(trie, url)] +function searchAll(trie: Trie, url: URL) { + return [...trie.search(url)] } describe('trie', () => { - describe('create', () => { + describe('constructor', () => { it('creates a trie with empty nodes', () => { - let trie = create() + let trie = new Trie() expect(trie.static).toEqual({}) expect(trie.variable).toEqual(new Map()) expect(trie.wildcard).toEqual(new Map()) @@ -29,9 +22,9 @@ describe('trie', () => { describe('insert', () => { it('inserts a simple static pathname pattern', () => { - let trie = create() + let trie = new Trie() let pattern = parse('users/list') - insert(trie, createMatch(pattern)) + trie.insert(pattern, null) // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) expect(trie.next?.next?.static['users']).toBeTruthy() @@ -39,9 +32,9 @@ describe('trie', () => { }) it('inserts a pattern with dynamic segment', () => { - let trie = create() + let trie = new Trie() let pattern = parse('users/:id') - insert(trie, createMatch(pattern)) + trie.insert(pattern, null) // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) expect(trie.next?.next?.static['users']).toBeTruthy() @@ -49,9 +42,9 @@ describe('trie', () => { }) it('inserts a full URL pattern', () => { - let trie = create() + let trie = new Trie() let pattern = parse('https://example.com/users/:id') - insert(trie, createMatch(pattern)) + trie.insert(pattern, null) // Protocol level expect(trie.static['https']).toBeTruthy() @@ -66,9 +59,9 @@ describe('trie', () => { }) it('inserts patterns with optional segments', () => { - let trie = create() + let trie = new Trie() let pattern = parse('api/(v:major(.:minor)/)run') - insert(trie, createMatch(pattern)) + trie.insert(pattern, null) // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) let pathnameLevel = trie.next?.next @@ -86,14 +79,14 @@ describe('trie', () => { }) it('inserts multiple patterns with shared prefixes', () => { - let trie = create() + let trie = new Trie() let pattern1 = parse('users/:id') let pattern2 = parse('users/admin') let pattern3 = parse('posts/:id') - insert(trie, createMatch(pattern1)) - insert(trie, createMatch(pattern2)) - insert(trie, createMatch(pattern3)) + trie.insert(pattern1, null) + trie.insert(pattern2, null) + trie.insert(pattern3, null) // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) let pathnameLevel = trie.next?.next @@ -109,9 +102,9 @@ describe('trie', () => { }) it('inserts a pattern with a wildcard', () => { - let trie = create() + let trie = new Trie() let pattern = parse('files/*path') - insert(trie, createMatch(pattern)) + trie.insert(pattern, null) // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) let pathnameLevel = trie.next?.next @@ -123,9 +116,9 @@ describe('trie', () => { }) it('inserts a pattern with wildcard after static prefix', () => { - let trie = create() + let trie = new Trie() let pattern = parse('assets/images/*file') - insert(trie, createMatch(pattern)) + trie.insert(pattern, null) // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) let pathnameLevel = trie.next?.next @@ -136,9 +129,9 @@ describe('trie', () => { }) it('inserts a pattern with inline wildcard', () => { - let trie = create() + let trie = new Trie() let pattern = parse('files/prefix-*rest') - insert(trie, createMatch(pattern)) + trie.insert(pattern, null) // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) let pathnameLevel = trie.next?.next @@ -149,9 +142,9 @@ describe('trie', () => { }) it('inserts a pattern with anonymous wildcard', () => { - let trie = create() + let trie = new Trie() let pattern = parse('api/*') - insert(trie, createMatch(pattern)) + trie.insert(pattern, null) // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) let pathnameLevel = trie.next?.next @@ -161,9 +154,9 @@ describe('trie', () => { }) it('inserts a pattern with wildcard followed by static segment', () => { - let trie = create() + let trie = new Trie() let pattern = parse('files/*path/details') - insert(trie, createMatch(pattern)) + trie.insert(pattern, null) // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) let pathnameLevel = trie.next?.next @@ -174,9 +167,9 @@ describe('trie', () => { }) it('inserts a pattern with wildcard followed by multiple segments', () => { - let trie = create() + let trie = new Trie() let pattern = parse('files/*path/foo/bar') - insert(trie, createMatch(pattern)) + trie.insert(pattern, null) // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) let pathnameLevel = trie.next?.next @@ -187,9 +180,9 @@ describe('trie', () => { }) it('inserts a pattern with wildcard followed by dynamic segment', () => { - let trie = create() + let trie = new Trie() let pattern = parse('files/*path/:id') - insert(trie, createMatch(pattern)) + trie.insert(pattern, null) // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) let pathnameLevel = trie.next?.next @@ -202,78 +195,78 @@ describe('trie', () => { describe('search', () => { it('matches a simple static pathname pattern', () => { - let trie = create() + let trie = new Trie() let pattern = parse('users/list') - insert(trie, createMatch(pattern)) + trie.insert(pattern, pattern) let matches = searchAll(trie, new URL('https://example.com/users/list')) expect(matches.length).toBe(1) - expect(matches[0]?.pattern).toBe(pattern) + expect(matches[0]?.data).toBe(pattern) }) it('returns no matches for non-matching URL', () => { - let trie = create() + let trie = new Trie() let pattern = parse('users/list') - insert(trie, createMatch(pattern)) + trie.insert(pattern, pattern) let matches = searchAll(trie, new URL('https://example.com/posts/list')) expect(matches.length).toBe(0) }) it('matches a pattern with dynamic segment', () => { - let trie = create() + let trie = new Trie() let pattern = parse('users/:id') - insert(trie, createMatch(pattern)) + trie.insert(pattern, pattern) let matches = searchAll(trie, new URL('https://example.com/users/123')) expect(matches.length).toBe(1) - expect(matches[0]?.pattern).toBe(pattern) + expect(matches[0]?.data).toBe(pattern) }) it('matches a full URL pattern', () => { - let trie = create() + let trie = new Trie() let pattern = parse('https://example.com/users/:id') - insert(trie, createMatch(pattern)) + trie.insert(pattern, pattern) let matches = searchAll(trie, new URL('https://example.com/users/456')) expect(matches.length).toBe(1) - expect(matches[0]?.pattern).toBe(pattern) + expect(matches[0]?.data).toBe(pattern) }) it('does not match wrong protocol', () => { - let trie = create() + let trie = new Trie() let pattern = parse('https://example.com/users') - insert(trie, createMatch(pattern)) + trie.insert(pattern, pattern) let matches = searchAll(trie, new URL('http://example.com/users')) expect(matches.length).toBe(0) }) it('does not match wrong hostname', () => { - let trie = create() + let trie = new Trie() let pattern = parse('https://example.com/users') - insert(trie, createMatch(pattern)) + trie.insert(pattern, pattern) let matches = searchAll(trie, new URL('https://other.com/users')) expect(matches.length).toBe(0) }) it('matches a pattern with wildcard', () => { - let trie = create() + let trie = new Trie() let pattern = parse('files/*path') - insert(trie, createMatch(pattern)) + trie.insert(pattern, pattern) let matches = searchAll(trie, new URL('https://example.com/files/a/b/c')) expect(matches.length).toBe(1) - expect(matches[0]?.pattern).toBe(pattern) + expect(matches[0]?.data).toBe(pattern) }) it('matches multiple patterns with shared prefix', () => { - let trie = create() + let trie = new Trie() let pattern1 = parse('users/:id') let pattern2 = parse('users/admin') - insert(trie, createMatch(pattern1)) - insert(trie, createMatch(pattern2)) + trie.insert(pattern1, pattern1) + trie.insert(pattern2, pattern2) // Should match the static pattern let matches1 = searchAll(trie, new URL('https://example.com/users/admin')) @@ -282,7 +275,7 @@ describe('trie', () => { // Should only match the dynamic pattern let matches2 = searchAll(trie, new URL('https://example.com/users/123')) expect(matches2.length).toBe(1) - expect(matches2[0]?.pattern).toBe(pattern1) + expect(matches2[0]?.data).toBe(pattern1) }) }) }) diff --git a/packages/route-pattern/src/lib2/matchers/trie.ts b/packages/route-pattern/src/lib2/matchers/trie.ts index 42a10811322..1889bcf91b8 100644 --- a/packages/route-pattern/src/lib2/matchers/trie.ts +++ b/packages/route-pattern/src/lib2/matchers/trie.ts @@ -1,177 +1,191 @@ import * as RoutePattern from '../route-pattern/index.ts' import * as RE from '../regexp.ts' -type Trie = { - static: Record - variable: Map - wildcard: Map - next: Trie | undefined - match: Match | undefined -} +const SEPARATORS = ['', '.', '/'] -export type Match = { - pattern: RoutePattern.AST - paramIndices: [protocol: Array, hostname: Array, pathname: Array] -} +type TrieIndex = [partIndex: number, segmentIndex: number] -export function create(): Trie { - return { - static: {}, - variable: new Map(), - wildcard: new Map(), - next: undefined, - match: undefined, - } +type Match = { + paramNames: Array + data: data } -function keyToRegExp(key: string): RegExp { - let source = key - .split(/\{:\}|\{\*\}/) - .map(RE.escape) - .join('([^/]*)') - return new RegExp(`^${source}$`) -} +export class Trie { + static: Record | undefined> = {} + variable: Map]> = new Map() + wildcard: Map]> = new Map() + next?: Trie + match?: Match + + insert(pattern: RoutePattern.AST, data: data) { + for (let variant of RoutePattern.variants(pattern)) { + let match: Match = { + paramNames: variant.paramNames, + data, + } -const separators = ['', '.', '/'] as const + let trie: Trie = this + let index: TrieIndex = [0, 0] + while (true) { + if (index[0] === variant.key.length) { + trie.match = match + break + } -type Index = [partIndex: number, segmentIndex: number] + let part = variant.key[index[0]] + if (index[1] >= part.length) { + if (!trie.next) trie.next = new Trie() + trie = trie.next + index[0] += 1 + index[1] = 0 + continue + } -// insert(trie, pattern, data: data) -// Data: { pattern, order, component } -export function insert(root: Trie, match: Match) { - for (let variant of RoutePattern.variants(match.pattern)) { - let trie = root - let index: Index = [0, 0] - while (true) { - if (index[0] === variant.key.length) { - trie.match = match - break - } + let segment = part[index[1]] + + let hasWildcard = segment.includes('{*}') + if (hasWildcard) { + let segments = part.slice(index[1]) + let key = segments.join(SEPARATORS[index[0]]) + // todo: check if key in wildcard map + if (!trie.next) trie.next = new Trie() + let regexp = Trie.#keyToRegExp(key, SEPARATORS[index[0]]) + trie.wildcard.set(key, [regexp, trie.next]) + index[0] += 1 + index[1] = 0 + trie = trie.next + continue + } - let part = variant.key[index[0]] - if (index[1] >= part.length) { - if (!trie.next) trie.next = create() - trie = trie.next - index[0] += 1 - index[1] = 0 - continue + let hasVariable = segment.includes('{:}') + if (hasVariable) { + let next = trie.variable.get(segment) + if (!next) { + let regexp = Trie.#keyToRegExp(segment, SEPARATORS[index[0]]) + next = [regexp, new Trie()] + trie.variable.set(segment, next) + } + trie = next[1] + index[1] += 1 + continue + } + + let next = trie.static[segment] + if (!next) { + next = new Trie() + trie.static[segment] = next + } + trie = next + index[1] += 1 } + } + } - let segment = part[index[1]] - - let hasWildcard = segment.includes('{*}') - if (hasWildcard) { - let segments = part.slice(index[1]) - let key = segments.join(separators[index[0]]) - let regexp = keyToRegExp(key) - if (!trie.next) trie.next = create() - trie.wildcard.set(key, [regexp, trie.next]) - index[0] += 1 - index[1] = 0 - trie = trie.next + *search(url: URL): Generator> { + let protocol = url.protocol.slice(0, -1) + let hostname = url.hostname.split('.').reverse() + let pathname = url.pathname.slice(1).split('/') + let query = [[protocol], hostname, pathname] + + type State = { + index: TrieIndex + trie: Trie + paramValues: [protocol: Array, hostname: Array, pathname: Array] + } + let stack: Array = [ + { + index: [0, 0], + trie: this, + paramValues: [[], [], []], + }, + ] + + while (stack.length > 0) { + let state = stack.pop()! + + if (state.index[0] === query.length) { + if (state.trie.match) { + yield state.trie.match + } continue } - let hasVariable = segment.includes('{:}') - if (hasVariable) { - let next = trie.variable.get(segment) - if (!next) { - next = [keyToRegExp(segment), create()] - trie.variable.set(segment, next) + let part = query[state.index[0]] + if (state.index[1] === part.length) { + if (state.trie.next) { + state.index[0] += 1 + state.index[1] = 0 + state.trie = state.trie.next + stack.push(state) } - trie = next[1] - index[1] += 1 continue } - let next = trie.static[segment] - if (!next) { - next = create() - trie.static[segment] = next - } - trie = next - index[1] += 1 - } - } -} + let segment = part[state.index[1]] -type SearchState = { - index: Index - trie: Trie - paramValues: [protocol: Array, hostname: Array, pathname: Array] -} - -export function* search(root: Trie, url: URL): Generator { - let protocol = url.protocol.slice(0, -1) - let hostname = url.hostname.split('.').reverse() - let pathname = url.pathname.slice(1).split('/') - let query = [[protocol], hostname, pathname] + let staticMatch = state.trie.static[segment] + if (staticMatch) { + stack.push({ + index: [state.index[0], state.index[1] + 1], + trie: staticMatch, + paramValues: state.paramValues, + }) + } - let stack: Array = [{ index: [0, 0], trie: root, paramValues: [[], [], []] }] - while (stack.length > 0) { - let state = stack.pop()! + for (let [regexp, trie] of state.trie.variable.values()) { + let match = regexp.exec(segment) + if (match) { + let paramValues = structuredClone(state.paramValues) + paramValues[state.index[0]].push(...match.slice(1)) + stack.push({ + index: [state.index[0], state.index[1] + 1], + trie, + paramValues, + }) + } + } - if (state.index[0] === query.length) { - if (state.trie.match) { - yield state.trie.match + for (let [regexp, trie] of state.trie.wildcard.values()) { + let key = part.slice(state.index[1]).join(SEPARATORS[state.index[0]]) + let match = regexp.exec(key) + if (match) { + let paramValues = structuredClone(state.paramValues) + paramValues[state.index[0]].push(...match.slice(1)) + stack.push({ + index: [state.index[0] + 1, 0], + trie, + paramValues, + }) + } } - continue - } - let part = query[state.index[0]] - if (state.index[1] === part.length) { - if (state.trie.next) { + // Consider skipping an entire part + // For example, a pattern like `://remix.run/about` + // will want to "skip" the protocol + // todo: better explanation + if (state.index[1] === 0 && state.trie.next) { state.index[0] += 1 state.index[1] = 0 state.trie = state.trie.next stack.push(state) } - continue } + } - let segment = part[state.index[1]] - - let staticMatch = state.trie.static[segment] - if (staticMatch) { - stack.push({ - index: [state.index[0], state.index[1] + 1], - trie: staticMatch, - paramValues: state.paramValues, + static #keyToRegExp(key: string, separator: string): RegExp { + let variablePattern = `[^${RE.escape(separator)}]*` + let wildcardPattern = '.*' + + let source = key + // use capture group so that `split` includes the delimiters in the result + .split(/(\{:\}|\{\*\})/) + .map((part) => { + if (part === '{:}') return `(${variablePattern})` + if (part === '{*}') return `(${wildcardPattern})` + return RE.escape(part) }) - } + .join('') - for (let [regexp, trie] of state.trie.variable.values()) { - let match = regexp.exec(segment) - if (match) { - let paramValues = structuredClone(state.paramValues) - paramValues[state.index[0]].push(...match.slice(1)) - stack.push({ - index: [state.index[0], state.index[1] + 1], - trie, - paramValues, - }) - } - } - - for (let [regexp, trie] of state.trie.wildcard.values()) { - let key = part.slice(state.index[1]).join(separators[state.index[0]]) - let match = regexp.exec(key) - if (match) { - let paramValues = structuredClone(state.paramValues) - paramValues[state.index[0]].push(...match.slice(1)) - stack.push({ - index: [state.index[0] + 1, 0], - trie, - paramValues, - }) - } - } - - if (state.trie.next) { - state.index[0] += 1 - state.index[1] = 0 - state.trie = state.trie.next - stack.push(state) - } + return new RegExp(`^${source}$`) } } diff --git a/packages/route-pattern/src/lib2/part/variants.ts b/packages/route-pattern/src/lib2/part/variants.ts index 0f372b7ba52..bd7f5be40c3 100644 --- a/packages/route-pattern/src/lib2/part/variants.ts +++ b/packages/route-pattern/src/lib2/part/variants.ts @@ -2,14 +2,14 @@ import type { AST } from './ast' export type Variant = { key: string - paramIndices: Array + paramNames: Array } export function variants(ast: AST): Array { let result: Array = [] let q: Array<{ index: number; variant: Variant }> = [ - { index: 0, variant: { key: '', paramIndices: [] } }, + { index: 0, variant: { key: '', paramNames: [] } }, ] while (q.length > 0) { let { index, variant } = q.pop()! @@ -34,7 +34,7 @@ export function variants(ast: AST): Array { if (token.type === ':') { variant.key += '{:}' - variant.paramIndices.push(token.nameIndex) + variant.paramNames.push(ast.paramNames[token.nameIndex]) q.push({ index: index + 1, variant }) continue } @@ -42,7 +42,7 @@ export function variants(ast: AST): Array { if (token.type === '*') { variant.key += '{*}' if (token.nameIndex) { - variant.paramIndices.push(token.nameIndex) + variant.paramNames.push(ast.paramNames[token.nameIndex]) } q.push({ index: index + 1, variant }) continue diff --git a/packages/route-pattern/src/lib2/route-pattern/variants.ts b/packages/route-pattern/src/lib2/route-pattern/variants.ts index 7e1d8e9b19c..9aaa777056d 100644 --- a/packages/route-pattern/src/lib2/route-pattern/variants.ts +++ b/packages/route-pattern/src/lib2/route-pattern/variants.ts @@ -1,7 +1,12 @@ import type { AST } from './ast.ts' import * as Part from '../part/index.ts' -export function* variants(pattern: AST) { +type Variant = { + key: Array> + paramNames: Array +} + +export function* variants(pattern: AST): Generator { let protocols = pattern.protocol ? Part.variants(pattern.protocol) : undefined let hostnames = pattern.hostname ? Part.variants(pattern.hostname) : undefined let pathnames = pattern.pathname ? Part.variants(pattern.pathname) : undefined @@ -9,17 +14,17 @@ export function* variants(pattern: AST) { for (let protocol of protocols ?? [null]) { for (let hostname of hostnames ?? [null]) { for (let pathname of pathnames ?? [null]) { + let paramNames: Array = [] + paramNames.push(...(protocol?.paramNames ?? [])) + paramNames.push(...(hostname?.paramNames ?? [])) + paramNames.push(...(pathname?.paramNames ?? [])) yield { key: [ protocol ? [protocol.key] : [], hostname?.key.split('.').reverse() ?? [], pathname?.key.split('/') ?? [], ], - paramIndices: [ - protocol?.paramIndices ?? [], - hostname?.paramIndices ?? [], - pathname?.paramIndices ?? [], - ], + paramNames, } } } diff --git a/packages/route-pattern/src/lib2/trie.test.ts b/packages/route-pattern/src/lib2/trie.test.ts deleted file mode 100644 index 7a7efead9df..00000000000 --- a/packages/route-pattern/src/lib2/trie.test.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { parse } from './route-pattern/parse.ts' -import { create } from './trie.ts' - -describe('trie', () => { - describe('create', () => { - it('creates a trie with root node', () => { - let trie = create() - expect(trie.root).toBeTruthy() - expect(trie.root.static).toEqual({}) - expect(trie.root.variable).toEqual(new Map()) - expect(trie.root.next).toBe(undefined) - expect(trie.root.match).toBe(undefined) - }) - }) - - describe('add', () => { - it('adds a simple static pathname pattern', () => { - let trie = create() - let pattern = parse('users/list') - trie._add(pattern) - - // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) - expect(trie.root.next?.next?.static['users']).toBeTruthy() - expect(trie.root.next?.next?.static['users']?.static['list']).toBeTruthy() - }) - - it('adds a pattern with dynamic segment', () => { - let trie = create() - let pattern = parse('users/:id') - trie._add(pattern) - - // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) - expect(trie.root.next?.next?.static['users']).toBeTruthy() - expect(trie.root.next?.next?.static['users']?.variable.has('{:}')).toBeTruthy() - }) - - it('adds a full URL pattern', () => { - let trie = create() - let pattern = parse('https://example.com/users/:id') - trie._add(pattern) - - // Protocol level - expect(trie.root.static['https']).toBeTruthy() - // Should have a next pointer to move to hostname level - expect(trie.root.static['https']?.next).toBeTruthy() - // Hostname level - let hostnameLevel = trie.root.static['https']?.next - expect(hostnameLevel?.static['example']).toBeTruthy() - expect(hostnameLevel?.static['example']?.static['com']).toBeTruthy() - // Should have a next pointer to move to pathname level - expect(hostnameLevel?.static['example']?.static['com']?.next).toBeTruthy() - }) - - it('adds patterns with optional segments', () => { - let trie = create() - let pattern = parse('api/(v:major(.:minor)/)run') - trie._add(pattern) - - // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) - let pathnameLevel = trie.root.next?.next - - // Should have multiple variants added to the trie - // Variant 1: api/run - expect(pathnameLevel?.static['api']).toBeTruthy() - expect(pathnameLevel?.static['api']?.static['run']).toBeTruthy() - - // Variant 2: api/v{:}/run - expect(pathnameLevel?.static['api']?.variable.get('v{:}')).toBeTruthy() - - // Variant 3: api/v{:}.{:}/run - expect(pathnameLevel?.static['api']?.variable.get('v{:}.{:}')).toBeTruthy() - }) - - it('adds multiple patterns with shared prefixes', () => { - let trie = create() - let pattern1 = parse('users/:id') - let pattern2 = parse('users/admin') - let pattern3 = parse('posts/:id') - - trie._add(pattern1) - trie._add(pattern2) - trie._add(pattern3) - - // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) - let pathnameLevel = trie.root.next?.next - - // Users branch - expect(pathnameLevel?.static['users']).toBeTruthy() - expect(pathnameLevel?.static['users']?.static['admin']).toBeTruthy() - expect(pathnameLevel?.static['users']?.variable.has('{:}')).toBeTruthy() - - // Posts branch - expect(pathnameLevel?.static['posts']).toBeTruthy() - expect(pathnameLevel?.static['posts']?.variable.has('{:}')).toBeTruthy() - }) - - it('adds a pattern with a wildcard', () => { - let trie = create() - let pattern = parse('files/*path') - trie._add(pattern) - - // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) - let pathnameLevel = trie.root.next?.next - - // Should have static 'files' segment - expect(pathnameLevel?.static['files']).toBeTruthy() - // Wildcard should be stored in the wildcard map - expect(pathnameLevel?.static['files']?.wildcard.has('{*}')).toBeTruthy() - }) - - it('adds a pattern with wildcard after static prefix', () => { - let trie = create() - let pattern = parse('assets/images/*file') - trie._add(pattern) - - // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) - let pathnameLevel = trie.root.next?.next - - expect(pathnameLevel?.static['assets']).toBeTruthy() - expect(pathnameLevel?.static['assets']?.static['images']).toBeTruthy() - expect(pathnameLevel?.static['assets']?.static['images']?.wildcard.has('{*}')).toBeTruthy() - }) - - it('adds a pattern with inline wildcard', () => { - let trie = create() - let pattern = parse('files/prefix-*rest') - trie._add(pattern) - - // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) - let pathnameLevel = trie.root.next?.next - - expect(pathnameLevel?.static['files']).toBeTruthy() - // Wildcard key should include the prefix - expect(pathnameLevel?.static['files']?.wildcard.has('prefix-{*}')).toBeTruthy() - }) - - it('adds a pattern with anonymous wildcard', () => { - let trie = create() - let pattern = parse('api/*') - trie._add(pattern) - - // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) - let pathnameLevel = trie.root.next?.next - - expect(pathnameLevel?.static['api']).toBeTruthy() - expect(pathnameLevel?.static['api']?.wildcard.has('{*}')).toBeTruthy() - }) - - it('adds a pattern with wildcard followed by static segment', () => { - let trie = create() - let pattern = parse('files/*path/details') - trie._add(pattern) - - // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) - let pathnameLevel = trie.root.next?.next - - expect(pathnameLevel?.static['files']).toBeTruthy() - // Wildcard key should include the segments after the wildcard - expect(pathnameLevel?.static['files']?.wildcard.has('{*}/details')).toBeTruthy() - }) - - it('adds a pattern with wildcard followed by multiple segments', () => { - let trie = create() - let pattern = parse('files/*path/foo/bar') - trie._add(pattern) - - // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) - let pathnameLevel = trie.root.next?.next - - expect(pathnameLevel?.static['files']).toBeTruthy() - // Wildcard key should include all segments after the wildcard - expect(pathnameLevel?.static['files']?.wildcard.has('{*}/foo/bar')).toBeTruthy() - }) - - it('adds a pattern with wildcard followed by dynamic segment', () => { - let trie = create() - let pattern = parse('files/*path/:id') - trie._add(pattern) - - // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) - let pathnameLevel = trie.root.next?.next - - expect(pathnameLevel?.static['files']).toBeTruthy() - // Wildcard key should include the dynamic segment after the wildcard - expect(pathnameLevel?.static['files']?.wildcard.has('{*}/{:}')).toBeTruthy() - }) - }) - - describe('match', () => { - it('matches a simple static pathname pattern', () => { - let trie = create() - let pattern = parse('users/list') - trie._add(pattern) - - let matches = [...trie._match(new URL('https://example.com/users/list'))] - expect(matches.length).toBe(1) - expect(matches[0]?.pattern).toBe(pattern) - }) - - it('returns no matches for non-matching URL', () => { - let trie = create() - let pattern = parse('users/list') - trie._add(pattern) - - let matches = [...trie._match(new URL('https://example.com/posts/list'))] - expect(matches.length).toBe(0) - }) - - it('matches a pattern with dynamic segment', () => { - let trie = create() - let pattern = parse('users/:id') - trie._add(pattern) - - let matches = [...trie._match(new URL('https://example.com/users/123'))] - expect(matches.length).toBe(1) - expect(matches[0]?.pattern).toBe(pattern) - expect(matches[0]?.params).toEqual({ id: '123' }) - }) - - it('matches a full URL pattern', () => { - let trie = create() - let pattern = parse('https://example.com/users/:id') - trie._add(pattern) - - let matches = [...trie._match(new URL('https://example.com/users/456'))] - expect(matches.length).toBe(1) - expect(matches[0]?.pattern).toBe(pattern) - }) - - it('does not match wrong protocol', () => { - let trie = create() - let pattern = parse('https://example.com/users') - trie._add(pattern) - - let matches = [...trie._match(new URL('http://example.com/users'))] - expect(matches.length).toBe(0) - }) - - it('does not match wrong hostname', () => { - let trie = create() - let pattern = parse('https://example.com/users') - trie._add(pattern) - - let matches = [...trie._match(new URL('https://other.com/users'))] - expect(matches.length).toBe(0) - }) - - it('matches a pattern with wildcard', () => { - let trie = create() - let pattern = parse('files/*path') - trie._add(pattern) - - let matches = [...trie._match(new URL('https://example.com/files/a/b/c'))] - expect(matches.length).toBe(1) - expect(matches[0]?.pattern).toBe(pattern) - }) - - it('matches multiple patterns with shared prefix', () => { - let trie = create() - let pattern1 = parse('users/:id') - let pattern2 = parse('users/admin') - trie._add(pattern1) - trie._add(pattern2) - - // Should match the static pattern - let matches1 = [...trie._match(new URL('https://example.com/users/admin'))] - expect(matches1.length).toBe(2) // Both patterns match - - // Should only match the dynamic pattern - let matches2 = [...trie._match(new URL('https://example.com/users/123'))] - expect(matches2.length).toBe(1) - expect(matches2[0]?.pattern).toBe(pattern1) - }) - }) -}) diff --git a/packages/route-pattern/src/lib2/trie.ts b/packages/route-pattern/src/lib2/trie.ts deleted file mode 100644 index 7515c22a952..00000000000 --- a/packages/route-pattern/src/lib2/trie.ts +++ /dev/null @@ -1,267 +0,0 @@ -import * as RoutePattern from './route-pattern/index.ts' -import * as RE from './regexp.ts' - -type Trie = { - static: Record - variable: Map - wildcard: Map - next: Trie | undefined - match: InternalMatch | undefined -} - -type InternalMatch = { - order: number - pattern: RoutePattern.AST - paramIndices: Array> -} - -type Match = { - order: number - pattern: RoutePattern.AST - params: Record -} - -type MatchState = { index: [number, number]; trie: Trie; paramValues: Array> } - -function node(): Trie { - return { - static: {}, - variable: new Map(), - wildcard: new Map(), - next: undefined, - match: undefined, - } -} - -export function create() { - let root = node() - let size = 0 - return { - root, - get size() { - return size - }, - add(pattern: string) { - let x = RoutePattern.parse(pattern) - this._add(x) - }, - _add(pattern: RoutePattern.AST) { - let order = size - size += 1 - - for (let variant of RoutePattern.variants(pattern)) { - let match: InternalMatch = { - order, - pattern, - paramIndices: variant.paramIndices, - } - - let trie = root - let index = [0, 0] - while (true) { - if (index[0] === variant.key.length) { - trie.match = match - break - } - - let part = variant.key[index[0]] - if (index[1] >= part.length) { - if (!trie.next) { - trie.next = node() - } - trie = trie.next - index[0] += 1 - index[1] = 0 - continue - } - - let segment = part[index[1]] - - let hasWildcard = segment.includes('{*}') - if (hasWildcard) { - let segments = part.slice(index[1]) - let key = segments.join( - // prettier-ignore - index[0] === 1 ? '.' : - index[0] === 2 ? '/' : - '', - ) - let regexp = keyToRegExp(key) - let next = trie.next - if (!next) { - next = node() - trie.next = next - } - trie.wildcard.set(key, [regexp, next]) - index[0] += 1 - index[1] = 0 - trie = next - continue - } - - let hasVariable = segment.includes('{:}') - if (hasVariable) { - let next = trie.variable.get(segment) - if (!next) { - next = [keyToRegExp(segment), node()] - trie.variable.set(segment, next) - } - trie = next[1] - index[1] += 1 - continue - } - - let next = trie.static[segment] - if (!next) { - next = node() - trie.static[segment] = next - } - trie = next - index[1] += 1 - } - } - }, - match(url: URL) { - return this.matchByOrder(url) - }, - matchAny(url: URL): Match | null { - for (let match of this._match(url)) { - return match - } - return null - }, - matchByOrder(url: URL) { - let best: Match | null = null - for (let match of this._match(url)) { - if (match.order === 0) return match - if (best === null) { - best = match - continue - } - if (match.order < best.order) { - best = match - continue - } - } - return best - }, - *_match(url: URL): Generator { - let protocol = url.protocol.slice(0, -1) - let hostname = url.hostname.split('.').reverse() - let pathname = url.pathname.slice(1).split('/') - let query = [[protocol], hostname, pathname] - - let q: Array = [{ index: [0, 0], trie: root, paramValues: [[], [], []] }] - while (q.length > 0) { - let state = q.pop()! - - if (state.index[0] === query.length) { - if (state.trie.match) { - let { pattern } = state.trie.match - yield { - order: state.trie.match.order, - pattern, - params: toParams(state), - } - } - continue - } - - let part = query[state.index[0]] - if (state.index[1] === part.length) { - if (state.trie.next) { - state.index[0] += 1 - state.index[1] = 0 - state.trie = state.trie.next - q.push(state) - } - continue - } - - let segment = part[state.index[1]] - - let staticMatch = state.trie.static[segment] - if (staticMatch) { - q.push({ - index: [state.index[0], state.index[1] + 1], - trie: staticMatch, - paramValues: state.paramValues, - }) - } - - for (let [regexp, trie] of state.trie.variable.values()) { - let match = regexp.exec(segment) - if (match) { - let paramValues = structuredClone(state.paramValues) - paramValues[state.index[0]].push(...match.slice(1)) - q.push({ - index: [state.index[0], state.index[1] + 1], - trie, - paramValues, - }) - } - } - - for (let [regexp, trie] of state.trie.wildcard.values()) { - let key = part.slice(state.index[1]).join( - // prettier-ignore - state.index[0] === 1 ? '.' : - state.index[0] === 2 ? '/' : - '', - ) - let match = regexp.exec(key) - if (match) { - let paramValues = structuredClone(state.paramValues) - paramValues[state.index[0]].push(...match.slice(1)) - q.push({ - index: [state.index[0] + 1, 0], - trie, - paramValues, - }) - } - } - - if (state.trie.next) { - state.index[0] += 1 - state.index[1] = 0 - state.trie = state.trie.next - q.push(state) - } - } - }, - } -} - -function toParams(state: MatchState) { - let { pattern, paramIndices } = state.trie.match! - let { paramValues } = state - let names = [ - pattern.protocol?.paramNames ?? [], - pattern.hostname?.paramNames ?? [], - pattern.pathname?.paramNames ?? [], - ] - let params: Record = {} - names.forEach((part) => - part.forEach((name) => { - params[name] = undefined - }), - ) - - for (let partIndex = 0; partIndex < paramIndices.length; partIndex++) { - let part = paramIndices[partIndex] - for (let i = 0; i < part.length; i++) { - let name = names[partIndex][i] - let value = paramValues[partIndex][i] - params[name] = value - } - } - return params -} - -function keyToRegExp(key: string): RegExp { - let source = key - .split(/\{:\}|\{\*\}/) - .map(RE.escape) - .join('([^/]*)') - return new RegExp(`^${source}$`) -} From 9b9a6cbdf18d94c8a64f59bfc7d5b9bcaa2d5c79 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 12 Dec 2025 23:07:57 -0500 Subject: [PATCH 17/54] todos --- packages/route-pattern/src/lib2/matchers/trie.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/route-pattern/src/lib2/matchers/trie.ts b/packages/route-pattern/src/lib2/matchers/trie.ts index 1889bcf91b8..a1fee5dd10e 100644 --- a/packages/route-pattern/src/lib2/matchers/trie.ts +++ b/packages/route-pattern/src/lib2/matchers/trie.ts @@ -28,6 +28,7 @@ export class Trie { let index: TrieIndex = [0, 0] while (true) { if (index[0] === variant.key.length) { + // todo: what if `match` already exists (duplicate / conflict)? trie.match = match break } @@ -47,7 +48,7 @@ export class Trie { if (hasWildcard) { let segments = part.slice(index[1]) let key = segments.join(SEPARATORS[index[0]]) - // todo: check if key in wildcard map + // todo: get `next` from `trie.wildcard`, don't just make a new one everytime if (!trie.next) trie.next = new Trie() let regexp = Trie.#keyToRegExp(key, SEPARATORS[index[0]]) trie.wildcard.set(key, [regexp, trie.next]) @@ -96,6 +97,7 @@ export class Trie { { index: [0, 0], trie: this, + // todo: make `paramValues` just `Array` since match now has `paramNames` specific to the variant! paramValues: [[], [], []], }, ] From bd07dc1ce2703ed2692e24312dc8c0eed75eff19 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 12 Dec 2025 23:08:21 -0500 Subject: [PATCH 18/54] trie matcher --- .../src/lib2/matchers/matcher.ts | 8 +++--- .../route-pattern/src/lib2/matchers/trie.ts | 25 +++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/route-pattern/src/lib2/matchers/matcher.ts b/packages/route-pattern/src/lib2/matchers/matcher.ts index 2a7b146e5ef..106c14c7105 100644 --- a/packages/route-pattern/src/lib2/matchers/matcher.ts +++ b/packages/route-pattern/src/lib2/matchers/matcher.ts @@ -1,5 +1,7 @@ -export type Matcher = { - add: () => void - match: () => void +import type * as RoutePattern from '../route-pattern/index.ts' + +export type Matcher = { + add: (pattern: RoutePattern.AST, data: data) => void + match: (url: URL) => data | null size: number } diff --git a/packages/route-pattern/src/lib2/matchers/trie.ts b/packages/route-pattern/src/lib2/matchers/trie.ts index a1fee5dd10e..c0980ac0a25 100644 --- a/packages/route-pattern/src/lib2/matchers/trie.ts +++ b/packages/route-pattern/src/lib2/matchers/trie.ts @@ -1,5 +1,30 @@ import * as RoutePattern from '../route-pattern/index.ts' import * as RE from '../regexp.ts' +import type { Matcher } from './matcher.ts' + +export class TrieMatcher implements Matcher { + #trie: Trie = new Trie() + #size: number = 0 + + add(pattern: RoutePattern.AST, data: data) { + this.#trie.insert(pattern, data) + this.#size += 1 + } + + match(url: URL) { + // todo: "best" match via ranking + for (let match of this.#trie.search(url)) { + return match.data + } + return null + } + + get size() { + return this.#size + } +} + +// Trie -------------------------------------------------------------------------------------------- const SEPARATORS = ['', '.', '/'] From 4a6a9512533ed38e3fc24208cff843f035026857 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 12 Dec 2025 23:32:23 -0500 Subject: [PATCH 19/54] fix: add wildcard name even when that param is the first one (index = 0) --- packages/route-pattern/src/lib2/part/variants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/route-pattern/src/lib2/part/variants.ts b/packages/route-pattern/src/lib2/part/variants.ts index bd7f5be40c3..7e16bf17758 100644 --- a/packages/route-pattern/src/lib2/part/variants.ts +++ b/packages/route-pattern/src/lib2/part/variants.ts @@ -41,7 +41,7 @@ export function variants(ast: AST): Array { if (token.type === '*') { variant.key += '{*}' - if (token.nameIndex) { + if (token.nameIndex !== undefined) { variant.paramNames.push(ast.paramNames[token.nameIndex]) } q.push({ index: index + 1, variant }) From 0e2659f0fcf2a3d21a78e875096c391a248e6a9b Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 12 Dec 2025 23:33:30 -0500 Subject: [PATCH 20/54] params (for now without `undefined` for unmatched params, will add later) --- .../src/lib2/matchers/trie.test.ts | 9 +++++ .../route-pattern/src/lib2/matchers/trie.ts | 40 +++++++++++++------ .../src/lib2/route-pattern/variants.ts | 10 ++--- 3 files changed, 41 insertions(+), 18 deletions(-) diff --git a/packages/route-pattern/src/lib2/matchers/trie.test.ts b/packages/route-pattern/src/lib2/matchers/trie.test.ts index 685b17be0c6..fb38db30e54 100644 --- a/packages/route-pattern/src/lib2/matchers/trie.test.ts +++ b/packages/route-pattern/src/lib2/matchers/trie.test.ts @@ -202,6 +202,7 @@ describe('trie', () => { let matches = searchAll(trie, new URL('https://example.com/users/list')) expect(matches.length).toBe(1) expect(matches[0]?.data).toBe(pattern) + expect(matches[0]?.params).toEqual({}) }) it('returns no matches for non-matching URL', () => { @@ -221,6 +222,7 @@ describe('trie', () => { let matches = searchAll(trie, new URL('https://example.com/users/123')) expect(matches.length).toBe(1) expect(matches[0]?.data).toBe(pattern) + expect(matches[0]?.params).toEqual({ id: '123' }) }) it('matches a full URL pattern', () => { @@ -231,6 +233,7 @@ describe('trie', () => { let matches = searchAll(trie, new URL('https://example.com/users/456')) expect(matches.length).toBe(1) expect(matches[0]?.data).toBe(pattern) + expect(matches[0]?.params).toEqual({ id: '456' }) }) it('does not match wrong protocol', () => { @@ -259,6 +262,7 @@ describe('trie', () => { let matches = searchAll(trie, new URL('https://example.com/files/a/b/c')) expect(matches.length).toBe(1) expect(matches[0]?.data).toBe(pattern) + expect(matches[0]?.params).toEqual({ path: 'a/b/c' }) }) it('matches multiple patterns with shared prefix', () => { @@ -271,11 +275,16 @@ describe('trie', () => { // Should match the static pattern let matches1 = searchAll(trie, new URL('https://example.com/users/admin')) expect(matches1.length).toBe(2) // Both patterns match + // Static match has no params + expect(matches1.find((m) => m.data === pattern2)?.params).toEqual({}) + // Dynamic match captures 'admin' as the id + expect(matches1.find((m) => m.data === pattern1)?.params).toEqual({ id: 'admin' }) // Should only match the dynamic pattern let matches2 = searchAll(trie, new URL('https://example.com/users/123')) expect(matches2.length).toBe(1) expect(matches2[0]?.data).toBe(pattern1) + expect(matches2[0]?.params).toEqual({ id: '123' }) }) }) }) diff --git a/packages/route-pattern/src/lib2/matchers/trie.ts b/packages/route-pattern/src/lib2/matchers/trie.ts index c0980ac0a25..ff1cde4d457 100644 --- a/packages/route-pattern/src/lib2/matchers/trie.ts +++ b/packages/route-pattern/src/lib2/matchers/trie.ts @@ -32,6 +32,14 @@ type TrieIndex = [partIndex: number, segmentIndex: number] type Match = { paramNames: Array + // todo: could pre-compute unused paramNames per variant here to get `undefined` for those + data: data +} + +type Params = Record + +type SearchResult = { + params: Params data: data } @@ -107,7 +115,7 @@ export class Trie { } } - *search(url: URL): Generator> { + *search(url: URL): Generator> { let protocol = url.protocol.slice(0, -1) let hostname = url.hostname.split('.').reverse() let pathname = url.pathname.slice(1).split('/') @@ -116,23 +124,21 @@ export class Trie { type State = { index: TrieIndex trie: Trie - paramValues: [protocol: Array, hostname: Array, pathname: Array] + paramValues: Array } - let stack: Array = [ - { - index: [0, 0], - trie: this, - // todo: make `paramValues` just `Array` since match now has `paramNames` specific to the variant! - paramValues: [[], [], []], - }, - ] + let stack: Array = [{ index: [0, 0], trie: this, paramValues: [] }] while (stack.length > 0) { let state = stack.pop()! if (state.index[0] === query.length) { if (state.trie.match) { - yield state.trie.match + let { paramNames, data } = state.trie.match + let { paramValues } = state + yield { + params: Trie.#toParams(paramNames, paramValues), + data, + } } continue } @@ -163,7 +169,7 @@ export class Trie { let match = regexp.exec(segment) if (match) { let paramValues = structuredClone(state.paramValues) - paramValues[state.index[0]].push(...match.slice(1)) + paramValues.push(...match.slice(1)) stack.push({ index: [state.index[0], state.index[1] + 1], trie, @@ -177,7 +183,7 @@ export class Trie { let match = regexp.exec(key) if (match) { let paramValues = structuredClone(state.paramValues) - paramValues[state.index[0]].push(...match.slice(1)) + paramValues.push(...match.slice(1)) stack.push({ index: [state.index[0] + 1, 0], trie, @@ -215,4 +221,12 @@ export class Trie { return new RegExp(`^${source}$`) } + + static #toParams(paramNames: Array, paramValues: Array): Params { + let params: Params = {} + paramNames.forEach((name, i) => { + params[name] = paramValues[i] + }) + return params + } } diff --git a/packages/route-pattern/src/lib2/route-pattern/variants.ts b/packages/route-pattern/src/lib2/route-pattern/variants.ts index 9aaa777056d..150c26f482d 100644 --- a/packages/route-pattern/src/lib2/route-pattern/variants.ts +++ b/packages/route-pattern/src/lib2/route-pattern/variants.ts @@ -14,17 +14,17 @@ export function* variants(pattern: AST): Generator { for (let protocol of protocols ?? [null]) { for (let hostname of hostnames ?? [null]) { for (let pathname of pathnames ?? [null]) { - let paramNames: Array = [] - paramNames.push(...(protocol?.paramNames ?? [])) - paramNames.push(...(hostname?.paramNames ?? [])) - paramNames.push(...(pathname?.paramNames ?? [])) yield { key: [ protocol ? [protocol.key] : [], hostname?.key.split('.').reverse() ?? [], pathname?.key.split('/') ?? [], ], - paramNames, + paramNames: [ + ...(protocol?.paramNames ?? []), + ...(hostname?.paramNames ?? []), + ...(pathname?.paramNames ?? []), + ], } } } From 8b500e245364d38a94bc36a384964061bc65c931 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Sat, 13 Dec 2025 12:32:09 -0500 Subject: [PATCH 21/54] full params + explicit es2025 backports --- packages/route-pattern/src/lib2/es2025.ts | 18 ++++++++ packages/route-pattern/src/lib2/index.ts | 0 .../src/lib2/matchers/trie.test.ts | 36 +++++++++++++++ .../route-pattern/src/lib2/matchers/trie.ts | 46 +++++++++++++------ .../route-pattern/src/lib2/part/to-regexp.ts | 6 +-- packages/route-pattern/src/lib2/regexp.ts | 2 - 6 files changed, 89 insertions(+), 19 deletions(-) create mode 100644 packages/route-pattern/src/lib2/es2025.ts create mode 100644 packages/route-pattern/src/lib2/index.ts delete mode 100644 packages/route-pattern/src/lib2/regexp.ts diff --git a/packages/route-pattern/src/lib2/es2025.ts b/packages/route-pattern/src/lib2/es2025.ts new file mode 100644 index 00000000000..6d22200e8f9 --- /dev/null +++ b/packages/route-pattern/src/lib2/es2025.ts @@ -0,0 +1,18 @@ +/** + * Backport of [RegExp.escape](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/escape) + */ +export const RegExp_escape = (string: string): string => + string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +/** + * Backport of [Set.prototype.difference](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set/difference) + */ +export const Set_difference = (a: Set, b: Set): Set => { + let result = new Set() + for (let item of a) { + if (!b.has(item)) { + result.add(item) + } + } + return result +} diff --git a/packages/route-pattern/src/lib2/index.ts b/packages/route-pattern/src/lib2/index.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/route-pattern/src/lib2/matchers/trie.test.ts b/packages/route-pattern/src/lib2/matchers/trie.test.ts index fb38db30e54..dc20e48cab5 100644 --- a/packages/route-pattern/src/lib2/matchers/trie.test.ts +++ b/packages/route-pattern/src/lib2/matchers/trie.test.ts @@ -286,5 +286,41 @@ describe('trie', () => { expect(matches2[0]?.data).toBe(pattern1) expect(matches2[0]?.params).toEqual({ id: '123' }) }) + + it('matches a pattern with optional variable when optional is not matched', () => { + let trie = new Trie() + let pattern = parse('api/v:major(.:minor)') + trie.insert(pattern, pattern) + + // Match without the optional minor version - minor should be explicitly undefined + let matches = searchAll(trie, new URL('https://example.com/api/v2')) + expect(matches.length).toBe(1) + expect(matches[0]?.data).toBe(pattern) + expect(matches[0]?.params).toStrictEqual({ major: '2', minor: undefined }) + + // Match with the optional minor version + let matches2 = searchAll(trie, new URL('https://example.com/api/v2.1')) + expect(matches2.length).toBe(2) + expect(matches2[0]?.data).toBe(pattern) + expect(matches2[0]?.params).toStrictEqual({ major: '2', minor: '1' }) + }) + + it('matches a pattern with optional segment when optional is not matched', () => { + let trie = new Trie() + let pattern = parse('users/:id(/details)') + trie.insert(pattern, pattern) + + // Match without the optional segment + let matches = searchAll(trie, new URL('https://example.com/users/123')) + expect(matches.length).toBe(1) + expect(matches[0]?.data).toBe(pattern) + expect(matches[0]?.params).toEqual({ id: '123' }) + + // Match with the optional segment + let matches2 = searchAll(trie, new URL('https://example.com/users/123/details')) + expect(matches2.length).toBe(1) + expect(matches2[0]?.data).toBe(pattern) + expect(matches2[0]?.params).toEqual({ id: '123' }) + }) }) }) diff --git a/packages/route-pattern/src/lib2/matchers/trie.ts b/packages/route-pattern/src/lib2/matchers/trie.ts index ff1cde4d457..afe3a2b8eca 100644 --- a/packages/route-pattern/src/lib2/matchers/trie.ts +++ b/packages/route-pattern/src/lib2/matchers/trie.ts @@ -1,5 +1,5 @@ +import { RegExp_escape, Set_difference } from '../es2025.ts' import * as RoutePattern from '../route-pattern/index.ts' -import * as RE from '../regexp.ts' import type { Matcher } from './matcher.ts' export class TrieMatcher implements Matcher { @@ -31,8 +31,10 @@ const SEPARATORS = ['', '.', '/'] type TrieIndex = [partIndex: number, segmentIndex: number] type Match = { - paramNames: Array - // todo: could pre-compute unused paramNames per variant here to get `undefined` for those + paramNames: { + included: Iterable + excluded: Iterable + } data: data } @@ -51,9 +53,18 @@ export class Trie { match?: Match insert(pattern: RoutePattern.AST, data: data) { + let patternParamNames = new Set([ + ...(pattern.protocol?.paramNames ?? []), + ...(pattern.hostname?.paramNames ?? []), + ...(pattern.pathname?.paramNames ?? []), + ]) + for (let variant of RoutePattern.variants(pattern)) { let match: Match = { - paramNames: variant.paramNames, + paramNames: { + included: variant.paramNames, + excluded: Set_difference(patternParamNames, new Set(variant.paramNames)), + }, data, } @@ -132,12 +143,11 @@ export class Trie { let state = stack.pop()! if (state.index[0] === query.length) { - if (state.trie.match) { - let { paramNames, data } = state.trie.match - let { paramValues } = state + let { match } = state.trie + if (match) { yield { - params: Trie.#toParams(paramNames, paramValues), - data, + params: Trie.#toParams(match, state.paramValues), + data: match.data, } } continue @@ -206,7 +216,7 @@ export class Trie { } static #keyToRegExp(key: string, separator: string): RegExp { - let variablePattern = `[^${RE.escape(separator)}]*` + let variablePattern = `[^${RegExp_escape(separator)}]*` let wildcardPattern = '.*' let source = key @@ -215,18 +225,26 @@ export class Trie { .map((part) => { if (part === '{:}') return `(${variablePattern})` if (part === '{*}') return `(${wildcardPattern})` - return RE.escape(part) + return RegExp_escape(part) }) .join('') return new RegExp(`^${source}$`) } - static #toParams(paramNames: Array, paramValues: Array): Params { + static #toParams(match: Match, paramValues: Array): Params { let params: Params = {} - paramNames.forEach((name, i) => { + + for (let name of match.paramNames.excluded) { + params[name] = undefined + } + + let i = 0 + for (let name of match.paramNames.included) { params[name] = paramValues[i] - }) + i += 1 + } + return params } } diff --git a/packages/route-pattern/src/lib2/part/to-regexp.ts b/packages/route-pattern/src/lib2/part/to-regexp.ts index c861290164f..57b0dd37f2a 100644 --- a/packages/route-pattern/src/lib2/part/to-regexp.ts +++ b/packages/route-pattern/src/lib2/part/to-regexp.ts @@ -1,5 +1,5 @@ -import * as RE from '../regexp.ts' -import type { AST } from './ast' +import { RegExp_escape } from '../es2025.ts' +import type { AST } from './ast.ts' export function toRegExp(ast: AST, paramValueRE: RegExp): RegExp { let source = toRegExpSource(ast, paramValueRE) @@ -31,7 +31,7 @@ export function toRegExpSource(ast: AST, paramValueRE: RegExp): string { } if (token.type === 'text') { - source += RE.escape(token.text) + source += RegExp_escape(token.text) continue } diff --git a/packages/route-pattern/src/lib2/regexp.ts b/packages/route-pattern/src/lib2/regexp.ts deleted file mode 100644 index cd145714a33..00000000000 --- a/packages/route-pattern/src/lib2/regexp.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Polyfill for `RegExp.escape` */ -export const escape = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') From ef31f375ae2507a0ed791e834d3b1469fdf75ebd Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Sat, 13 Dec 2025 19:26:45 -0500 Subject: [PATCH 22/54] fix wildcard deduplication in trie branches --- packages/route-pattern/src/lib2/es2025.ts | 13 -------- .../route-pattern/src/lib2/matchers/trie.ts | 32 ++++++++++--------- 2 files changed, 17 insertions(+), 28 deletions(-) diff --git a/packages/route-pattern/src/lib2/es2025.ts b/packages/route-pattern/src/lib2/es2025.ts index 6d22200e8f9..81449ae9c6d 100644 --- a/packages/route-pattern/src/lib2/es2025.ts +++ b/packages/route-pattern/src/lib2/es2025.ts @@ -3,16 +3,3 @@ */ export const RegExp_escape = (string: string): string => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - -/** - * Backport of [Set.prototype.difference](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set/difference) - */ -export const Set_difference = (a: Set, b: Set): Set => { - let result = new Set() - for (let item of a) { - if (!b.has(item)) { - result.add(item) - } - } - return result -} diff --git a/packages/route-pattern/src/lib2/matchers/trie.ts b/packages/route-pattern/src/lib2/matchers/trie.ts index afe3a2b8eca..4929a35ae3b 100644 --- a/packages/route-pattern/src/lib2/matchers/trie.ts +++ b/packages/route-pattern/src/lib2/matchers/trie.ts @@ -1,4 +1,4 @@ -import { RegExp_escape, Set_difference } from '../es2025.ts' +import { RegExp_escape } from '../es2025.ts' import * as RoutePattern from '../route-pattern/index.ts' import type { Matcher } from './matcher.ts' @@ -47,23 +47,23 @@ type SearchResult = { export class Trie { static: Record | undefined> = {} - variable: Map]> = new Map() - wildcard: Map]> = new Map() + variable: Map }> = new Map() + wildcard: Map }> = new Map() next?: Trie match?: Match insert(pattern: RoutePattern.AST, data: data) { - let patternParamNames = new Set([ + let patternParamNames = [ ...(pattern.protocol?.paramNames ?? []), ...(pattern.hostname?.paramNames ?? []), ...(pattern.pathname?.paramNames ?? []), - ]) + ] for (let variant of RoutePattern.variants(pattern)) { let match: Match = { paramNames: { included: variant.paramNames, - excluded: Set_difference(patternParamNames, new Set(variant.paramNames)), + excluded: patternParamNames.filter((name) => !variant.paramNames.includes(name)), }, data, } @@ -92,13 +92,15 @@ export class Trie { if (hasWildcard) { let segments = part.slice(index[1]) let key = segments.join(SEPARATORS[index[0]]) - // todo: get `next` from `trie.wildcard`, don't just make a new one everytime - if (!trie.next) trie.next = new Trie() - let regexp = Trie.#keyToRegExp(key, SEPARATORS[index[0]]) - trie.wildcard.set(key, [regexp, trie.next]) + let next = trie.wildcard.get(key) + if (!next) { + let regexp = Trie.#keyToRegExp(key, SEPARATORS[index[0]]) + next = { regexp, trie: new Trie() } + trie.wildcard.set(key, next) + } + trie = next.trie index[0] += 1 index[1] = 0 - trie = trie.next continue } @@ -107,10 +109,10 @@ export class Trie { let next = trie.variable.get(segment) if (!next) { let regexp = Trie.#keyToRegExp(segment, SEPARATORS[index[0]]) - next = [regexp, new Trie()] + next = { regexp, trie: new Trie() } trie.variable.set(segment, next) } - trie = next[1] + trie = next.trie index[1] += 1 continue } @@ -175,7 +177,7 @@ export class Trie { }) } - for (let [regexp, trie] of state.trie.variable.values()) { + for (let { regexp, trie } of state.trie.variable.values()) { let match = regexp.exec(segment) if (match) { let paramValues = structuredClone(state.paramValues) @@ -188,7 +190,7 @@ export class Trie { } } - for (let [regexp, trie] of state.trie.wildcard.values()) { + for (let { regexp, trie } of state.trie.wildcard.values()) { let key = part.slice(state.index[1]).join(SEPARATORS[state.index[0]]) let match = regexp.exec(key) if (match) { From 9471879385f180af56e3ab6da043b22c81f07a47 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Sat, 13 Dec 2025 23:53:15 -0500 Subject: [PATCH 23/54] state paramNames as arrays --- packages/route-pattern/src/lib2/matchers/trie.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/route-pattern/src/lib2/matchers/trie.ts b/packages/route-pattern/src/lib2/matchers/trie.ts index 4929a35ae3b..cb28f77e4ae 100644 --- a/packages/route-pattern/src/lib2/matchers/trie.ts +++ b/packages/route-pattern/src/lib2/matchers/trie.ts @@ -32,8 +32,9 @@ type TrieIndex = [partIndex: number, segmentIndex: number] type Match = { paramNames: { - included: Iterable - excluded: Iterable + /** In the same order as they appear in their variant */ + included: Array + excluded: Array } data: data } @@ -237,15 +238,13 @@ export class Trie { static #toParams(match: Match, paramValues: Array): Params { let params: Params = {} - for (let name of match.paramNames.excluded) { + match.paramNames.excluded.forEach((name) => { params[name] = undefined - } + }) - let i = 0 - for (let name of match.paramNames.included) { + match.paramNames.included.forEach((name, i) => { params[name] = paramValues[i] - i += 1 - } + }) return params } From 057eb6a2766ea4c6492ed2b1ffd9444069ebe919 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Sat, 13 Dec 2025 23:54:09 -0500 Subject: [PATCH 24/54] ranking! --- .../src/lib2/matchers/trie.test.ts | 43 +++++++++++ .../route-pattern/src/lib2/matchers/trie.ts | 75 +++++++++++++++---- 2 files changed, 103 insertions(+), 15 deletions(-) diff --git a/packages/route-pattern/src/lib2/matchers/trie.test.ts b/packages/route-pattern/src/lib2/matchers/trie.test.ts index dc20e48cab5..e2d051107fd 100644 --- a/packages/route-pattern/src/lib2/matchers/trie.test.ts +++ b/packages/route-pattern/src/lib2/matchers/trie.test.ts @@ -322,5 +322,48 @@ describe('trie', () => { expect(matches2[0]?.data).toBe(pattern) expect(matches2[0]?.params).toEqual({ id: '123' }) }) + + it('ranks static matches higher than dynamic matches', () => { + let trie = new Trie() + let staticPattern = parse('users/@admin') + let dynamicPattern = parse('users/@:id') + trie.insert(staticPattern, staticPattern) + trie.insert(dynamicPattern, dynamicPattern) + + let matches = searchAll(trie, new URL('https://example.com/users/@admin')) + expect(matches.length).toBe(2) + + // Static match should have rank of all 0s for pathname segments + // Dynamic match should have rank of 1s where the variable matched + let staticMatch = matches.find((m) => m.data === staticPattern) + let dynamicMatch = matches.find((m) => m.data === dynamicPattern) + + // The rank array covers: protocol (5 chars "https") + hostname (3 "com" + 7 "example") + pathname (5 "users" + 5 "admin") + // Total: 5 + 3 + 7 + 5 + 5 = 25 + + expect(staticMatch).toStrictEqual({ + // prettier-ignore + rank: new Uint8Array([ + 3, 3, 3, 3, 3, // protocol "https" - skipped (3) + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, // hostname "com" + "example" - skipped (3) + 0, 0, 0, 0, 0, // pathname "users" - static (0) + 0, 0, 0, 0, 0, 0, // pathname "@admin" - static (0) + ]), + params: {}, + data: staticPattern, + }) + + expect(dynamicMatch).toStrictEqual({ + // prettier-ignore + rank: new Uint8Array([ + 3, 3, 3, 3, 3, // protocol "https" - skipped (3) + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, // hostname "com" + "example" - skipped (3) + 0, 0, 0, 0, 0, // pathname "users" - static (0) + 0, 1, 1, 1, 1, 1, // pathname "@admin" matched by @:id - variable (1) + ]), + params: { id: 'admin' }, + data: dynamicPattern, + }) + }) }) }) diff --git a/packages/route-pattern/src/lib2/matchers/trie.ts b/packages/route-pattern/src/lib2/matchers/trie.ts index cb28f77e4ae..70709d3aab4 100644 --- a/packages/route-pattern/src/lib2/matchers/trie.ts +++ b/packages/route-pattern/src/lib2/matchers/trie.ts @@ -12,11 +12,14 @@ export class TrieMatcher implements Matcher { } match(url: URL) { - // todo: "best" match via ranking + let best: SearchResult | null = null for (let match of this.#trie.search(url)) { - return match.data + if (best === null || match.rank < best.rank) { + best = match + } } - return null + // todo: also return params for this match + return best?.data ?? null } get size() { @@ -42,6 +45,7 @@ type Match = { type Params = Record type SearchResult = { + rank: Uint8Array params: Params data: data } @@ -135,12 +139,27 @@ export class Trie { let pathname = url.pathname.slice(1).split('/') let query = [[protocol], hostname, pathname] + let rankLength = protocol.length + hostname.forEach((segment) => (rankLength += segment.length)) + pathname.forEach((segment) => (rankLength += segment.length)) + type State = { index: TrieIndex trie: Trie paramValues: Array + /** 0 => static, 1 => variable, 2 => wilcard, 3 => skipped */ + rank: Uint8Array + rankIndex: number } - let stack: Array = [{ index: [0, 0], trie: this, paramValues: [] }] + let stack: Array = [ + { + index: [0, 0], + trie: this, + paramValues: [], + rank: new Uint8Array(rankLength), + rankIndex: 0, + }, + ] while (stack.length > 0) { let state = stack.pop()! @@ -149,6 +168,7 @@ export class Trie { let { match } = state.trie if (match) { yield { + rank: state.rank, params: Trie.#toParams(match, state.paramValues), data: match.data, } @@ -175,32 +195,52 @@ export class Trie { index: [state.index[0], state.index[1] + 1], trie: staticMatch, paramValues: state.paramValues, + rank: state.rank, + rankIndex: state.rankIndex + segment.length, }) } for (let { regexp, trie } of state.trie.variable.values()) { let match = regexp.exec(segment) if (match) { - let paramValues = structuredClone(state.paramValues) + let rank = state.rank.slice() + match.indices?.forEach((span, i) => { + if (i === 0) return // ignore span for entire match + rank.fill(1, state.rankIndex + span[0], state.rankIndex + span[1]) + }) + let paramValues = state.paramValues.slice() paramValues.push(...match.slice(1)) stack.push({ index: [state.index[0], state.index[1] + 1], trie, paramValues, + rank, + rankIndex: state.rankIndex + segment.length, }) } } for (let { regexp, trie } of state.trie.wildcard.values()) { - let key = part.slice(state.index[1]).join(SEPARATORS[state.index[0]]) - let match = regexp.exec(key) + let segments = part.slice(state.index[1]).join(SEPARATORS[state.index[0]]) + let match = regexp.exec(segments) if (match) { - let paramValues = structuredClone(state.paramValues) + let rank = state.rank.slice() + Object.entries(match.indices?.groups ?? {}).forEach(([group, span]) => { + if (group.startsWith('v_')) { + rank.fill(1, state.rankIndex + span[0], state.rankIndex + span[1]) + } + if (group.startsWith('w_')) { + rank.fill(2, state.rankIndex + span[0], state.rankIndex + span[1]) + } + }) + let paramValues = state.paramValues.slice() paramValues.push(...match.slice(1)) stack.push({ index: [state.index[0] + 1, 0], trie, paramValues, + rank, + rankIndex: state.rankIndex + segments.length, }) } } @@ -210,10 +250,14 @@ export class Trie { // will want to "skip" the protocol // todo: better explanation if (state.index[1] === 0 && state.trie.next) { - state.index[0] += 1 - state.index[1] = 0 - state.trie = state.trie.next - stack.push(state) + let length = query[state.index[0]].reduce((acc, segment) => acc + segment.length, 0) + stack.push({ + index: [state.index[0] + 1, 0], + trie: state.trie.next, + paramValues: state.paramValues, + rank: state.rank.slice().fill(3, state.rankIndex, state.rankIndex + length), + rankIndex: state.rankIndex + length, + }) } } } @@ -222,17 +266,18 @@ export class Trie { let variablePattern = `[^${RegExp_escape(separator)}]*` let wildcardPattern = '.*' + let i = 0 let source = key // use capture group so that `split` includes the delimiters in the result .split(/(\{:\}|\{\*\})/) .map((part) => { - if (part === '{:}') return `(${variablePattern})` - if (part === '{*}') return `(${wildcardPattern})` + if (part === '{:}') return `(?${variablePattern})` + if (part === '{*}') return `(?${wildcardPattern})` return RegExp_escape(part) }) .join('') - return new RegExp(`^${source}$`) + return new RegExp(`^${source}$`, 'd') } static #toParams(match: Match, paramValues: Array): Params { From 946cab33fac6fbcc6f5435274ee91c0abb40f7ed Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 15 Dec 2025 10:22:47 -0500 Subject: [PATCH 25/54] fix: wildcard rankIndex --- packages/route-pattern/src/lib2/matchers/trie.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/route-pattern/src/lib2/matchers/trie.ts b/packages/route-pattern/src/lib2/matchers/trie.ts index 70709d3aab4..98f091c9bad 100644 --- a/packages/route-pattern/src/lib2/matchers/trie.ts +++ b/packages/route-pattern/src/lib2/matchers/trie.ts @@ -221,8 +221,8 @@ export class Trie { } for (let { regexp, trie } of state.trie.wildcard.values()) { - let segments = part.slice(state.index[1]).join(SEPARATORS[state.index[0]]) - let match = regexp.exec(segments) + let remaining = part.slice(state.index[1]) + let match = regexp.exec(remaining.join(SEPARATORS[state.index[0]])) if (match) { let rank = state.rank.slice() Object.entries(match.indices?.groups ?? {}).forEach(([group, span]) => { @@ -240,7 +240,8 @@ export class Trie { trie, paramValues, rank, - rankIndex: state.rankIndex + segments.length, + rankIndex: + state.rankIndex + remaining.reduce((acc, segment) => acc + segment.length, 0), }) } } From dc911515152effaedc7f51c35b72e89155b4ad45 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 15 Dec 2025 15:12:37 -0500 Subject: [PATCH 26/54] better ranking --- .../src/lib2/matchers/trie.test.ts | 205 ++++++++++++++++-- .../route-pattern/src/lib2/matchers/trie.ts | 114 ++++++---- 2 files changed, 261 insertions(+), 58 deletions(-) diff --git a/packages/route-pattern/src/lib2/matchers/trie.test.ts b/packages/route-pattern/src/lib2/matchers/trie.test.ts index e2d051107fd..8200f2d34ac 100644 --- a/packages/route-pattern/src/lib2/matchers/trie.test.ts +++ b/packages/route-pattern/src/lib2/matchers/trie.test.ts @@ -333,37 +333,204 @@ describe('trie', () => { let matches = searchAll(trie, new URL('https://example.com/users/@admin')) expect(matches.length).toBe(2) - // Static match should have rank of all 0s for pathname segments - // Dynamic match should have rank of 1s where the variable matched let staticMatch = matches.find((m) => m.data === staticPattern) let dynamicMatch = matches.find((m) => m.data === dynamicPattern) - // The rank array covers: protocol (5 chars "https") + hostname (3 "com" + 7 "example") + pathname (5 "users" + 5 "admin") - // Total: 5 + 3 + 7 + 5 + 5 = 25 - expect(staticMatch).toStrictEqual({ - // prettier-ignore - rank: new Uint8Array([ - 3, 3, 3, 3, 3, // protocol "https" - skipped (3) - 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, // hostname "com" + "example" - skipped (3) - 0, 0, 0, 0, 0, // pathname "users" - static (0) - 0, 0, 0, 0, 0, 0, // pathname "@admin" - static (0) - ]), + rank: [ + '3', // protocol: "https" (skipped: 3) + '3', // hostname: "com" (skipped: 3) + '3', // hostname: "example" (skipped: 3) + '0', // pathname: "users" (static: 0) + '0', // pathname: "@admin" (static: 0) + ], params: {}, data: staticPattern, }) expect(dynamicMatch).toStrictEqual({ - // prettier-ignore - rank: new Uint8Array([ - 3, 3, 3, 3, 3, // protocol "https" - skipped (3) - 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, // hostname "com" + "example" - skipped (3) - 0, 0, 0, 0, 0, // pathname "users" - static (0) - 0, 1, 1, 1, 1, 1, // pathname "@admin" matched by @:id - variable (1) - ]), + rank: [ + '3', // protocol: "https" (skipped: 3) + '3', // hostname: "com" (skipped: 3) + '3', // hostname: "example" (skipped: 3) + '0', // pathname: "users" (static: 0) + '011111', // pathname: "@admin" (dynamic: "011111") + ], params: { id: 'admin' }, data: dynamicPattern, }) }) + + it('matches pattern with static prefix + wildcard in segment', () => { + let trie = new Trie() + let pattern = parse('files/image-*rest') + trie.insert(pattern, pattern) + + let matches = searchAll(trie, new URL('https://example.com/files/image-photo/gallery/2024')) + expect(matches.length).toBe(1) + expect(matches[0]).toStrictEqual({ + rank: [ + '3', // protocol: "https" (skipped: 3) + '3', // hostname: "com" (skipped: 3) + '3', // hostname: "example" (skipped: 3) + '0', // pathname: "files" (static: 0) + '00000022222', // pathname: "image-" (static) + "photo" (wildcard) + '2222222', // pathname: "gallery" (wildcard) + '2222', // pathname: "2024" (wildcard) + ], + params: { rest: 'photo/gallery/2024' }, + data: pattern, + }) + }) + + it('matches pattern with variable + static suffix in segment', () => { + let trie = new Trie() + let pattern = parse('assets/:name.png') + trie.insert(pattern, pattern) + + let matches = searchAll(trie, new URL('https://example.com/assets/logo.png')) + expect(matches.length).toBe(1) + expect(matches[0]).toStrictEqual({ + rank: [ + '3', // protocol: "https" (skipped: 3) + '3', // hostname: "com" (skipped: 3) + '3', // hostname: "example" (skipped: 3) + '0', // pathname: "assets" (static: 0) + '11110000', // pathname: "logo" (variable) + ".png" (static) + ], + params: { name: 'logo' }, + data: pattern, + }) + }) + + it('matches pattern with static + variable + static in same segment', () => { + let trie = new Trie() + let pattern = parse('api/v:version-beta') + trie.insert(pattern, pattern) + + let matches = searchAll(trie, new URL('https://example.com/api/v2-beta')) + expect(matches.length).toBe(1) + expect(matches[0]).toStrictEqual({ + rank: [ + '3', // protocol: "https" (skipped: 3) + '3', // hostname: "com" (skipped: 3) + '3', // hostname: "example" (skipped: 3) + '0', // pathname: "api" (static: 0) + '0100000', // pathname: "v" (static) + "2" (variable) + "-beta" (static) + ], + params: { version: '2' }, + data: pattern, + }) + }) + + it('matches pattern with static + wildcard + static across segments', () => { + let trie = new Trie() + let pattern = parse('docs/*path/edit') + trie.insert(pattern, pattern) + + let matches = searchAll(trie, new URL('https://example.com/docs/guides/intro/edit')) + expect(matches.length).toBe(1) + expect(matches[0]).toStrictEqual({ + rank: [ + '3', // protocol: "https" (skipped: 3) + '3', // hostname: "com" (skipped: 3) + '3', // hostname: "example" (skipped: 3) + '0', // pathname: "docs" (static: 0) + '222222', // pathname: "guides" (wildcard) + '22222', // pathname: "intro" (wildcard) + '0000', // pathname: "edit" (static) + ], + params: { path: 'guides/intro' }, + data: pattern, + }) + }) + + it('matches pattern with variable, wildcard, and static in later segments', () => { + let trie = new Trie() + let pattern = parse('org/:orgId/*path/settings') + trie.insert(pattern, pattern) + + let matches = searchAll(trie, new URL('https://example.com/org/acme/projects/web/settings')) + expect(matches.length).toBe(1) + expect(matches[0]).toStrictEqual({ + rank: [ + '3', // protocol: "https" (skipped: 3) + '3', // hostname: "com" (skipped: 3) + '3', // hostname: "example" (skipped: 3) + '0', // pathname: "org" (static: 0) + '1111', // pathname: "acme" (variable) + '22222222', // pathname: "projects" (wildcard) + '222', // pathname: "web" (wildcard) + '00000000', // pathname: "settings" (static) + ], + params: { orgId: 'acme', path: 'projects/web' }, + data: pattern, + }) + }) + + it('ranks patterns with more static content higher', () => { + let trie = new Trie() + let moreStatic = parse('files/images-*rest') + let lessStatic = parse('files/*rest') + trie.insert(moreStatic, moreStatic) + trie.insert(lessStatic, lessStatic) + + let matches = searchAll(trie, new URL('https://example.com/files/images-photo.jpg')) + expect(matches.length).toBe(2) + + let moreStaticMatch = matches.find((m) => m.data === moreStatic) + let lessStaticMatch = matches.find((m) => m.data === lessStatic) + + // More static should have lower rank (better) - string comparison works lexicographically + expect(moreStaticMatch!.rank.join(',') < lessStaticMatch!.rank.join(',')).toBe(true) + + expect(moreStaticMatch).toStrictEqual({ + rank: [ + '3', // protocol: "https" (skipped: 3) + '3', // hostname: "com" (skipped: 3) + '3', // hostname: "example" (skipped: 3) + '0', // pathname: "files" (static: 0) + '0000000222222222', // pathname: "images-" (static) + "photo.jpg" (wildcard) + ], + params: { rest: 'photo.jpg' }, + data: moreStatic, + }) + + expect(lessStaticMatch).toStrictEqual({ + rank: [ + '3', // protocol: "https" (skipped: 3) + '3', // hostname: "com" (skipped: 3) + '3', // hostname: "example" (skipped: 3) + '0', // pathname: "files" (static: 0) + '2222222222222222', // pathname: "images-photo.jpg" (all wildcard) + ], + params: { rest: 'images-photo.jpg' }, + data: lessStatic, + }) + }) + + it('matches complex pattern with multiple dynamic segments and wildcards', () => { + let trie = new Trie() + let pattern = parse('api/:version/users/:userId/*action') + trie.insert(pattern, pattern) + + let matches = searchAll(trie, new URL('https://example.com/api/v2/users/123/posts/create')) + expect(matches.length).toBe(1) + expect(matches[0]).toStrictEqual({ + rank: [ + '3', // protocol: "https" (skipped: 3) + '3', // hostname: "com" (skipped: 3) + '3', // hostname: "example" (skipped: 3) + '0', // pathname: "api" (static: 0) + '11', // pathname: "v2" (variable) + '0', // pathname: "users" (static: 0) + '111', // pathname: "123" (variable) + '22222', // pathname: "posts" (wildcard) + '222222', // pathname: "create" (wildcard) + ], + params: { version: 'v2', userId: '123', action: 'posts/create' }, + data: pattern, + }) + }) }) }) diff --git a/packages/route-pattern/src/lib2/matchers/trie.ts b/packages/route-pattern/src/lib2/matchers/trie.ts index 98f091c9bad..8dc87fbd42e 100644 --- a/packages/route-pattern/src/lib2/matchers/trie.ts +++ b/packages/route-pattern/src/lib2/matchers/trie.ts @@ -14,7 +14,7 @@ export class TrieMatcher implements Matcher { match(url: URL) { let best: SearchResult | null = null for (let match of this.#trie.search(url)) { - if (best === null || match.rank < best.rank) { + if (best === null || rankLessThan(match.rank, best.rank)) { best = match } } @@ -27,8 +27,30 @@ export class TrieMatcher implements Matcher { } } +// Rank -------------------------------------------------------------------------------------------- + +const RANK = { + skip: '3', + wildcard: '2', + variable: '1', + static: '0', +} + +type Rank = Array + +function rankLessThan(a: Rank, b: Rank) { + for (let i = 0; i < a.length; i++) { + let segmentA = a[i] + let segmentB = b[i] + if (segmentA < segmentB) return -1 + if (segmentA > segmentB) return 1 + return 0 + } +} + // Trie -------------------------------------------------------------------------------------------- +// todo: NOT_SEPARATORS: Array ? const SEPARATORS = ['', '.', '/'] type TrieIndex = [partIndex: number, segmentIndex: number] @@ -45,7 +67,7 @@ type Match = { type Params = Record type SearchResult = { - rank: Uint8Array + rank: Array params: Params data: data } @@ -139,25 +161,18 @@ export class Trie { let pathname = url.pathname.slice(1).split('/') let query = [[protocol], hostname, pathname] - let rankLength = protocol.length - hostname.forEach((segment) => (rankLength += segment.length)) - pathname.forEach((segment) => (rankLength += segment.length)) - type State = { index: TrieIndex trie: Trie paramValues: Array - /** 0 => static, 1 => variable, 2 => wilcard, 3 => skipped */ - rank: Uint8Array - rankIndex: number + rank: Rank } let stack: Array = [ { index: [0, 0], trie: this, paramValues: [], - rank: new Uint8Array(rankLength), - rankIndex: 0, + rank: [], }, ] @@ -191,57 +206,55 @@ export class Trie { let staticMatch = state.trie.static[segment] if (staticMatch) { + let rank = state.rank.slice() + rank.push(RANK.static) stack.push({ index: [state.index[0], state.index[1] + 1], trie: staticMatch, paramValues: state.paramValues, - rank: state.rank, - rankIndex: state.rankIndex + segment.length, + rank, }) } + let separator = SEPARATORS[state.index[0]] + for (let { regexp, trie } of state.trie.variable.values()) { let match = regexp.exec(segment) if (match) { - let rank = state.rank.slice() - match.indices?.forEach((span, i) => { - if (i === 0) return // ignore span for entire match - rank.fill(1, state.rankIndex + span[0], state.rankIndex + span[1]) - }) + let dynamic = Trie.#dynamicMatch(match, separator) + let paramValues = state.paramValues.slice() - paramValues.push(...match.slice(1)) + paramValues.push(...dynamic.paramValues) + + let rank = state.rank.slice() + rank.push(...dynamic.rank) + stack.push({ index: [state.index[0], state.index[1] + 1], trie, paramValues, rank, - rankIndex: state.rankIndex + segment.length, }) } } for (let { regexp, trie } of state.trie.wildcard.values()) { let remaining = part.slice(state.index[1]) - let match = regexp.exec(remaining.join(SEPARATORS[state.index[0]])) + let match = regexp.exec(remaining.join(separator)) if (match) { - let rank = state.rank.slice() - Object.entries(match.indices?.groups ?? {}).forEach(([group, span]) => { - if (group.startsWith('v_')) { - rank.fill(1, state.rankIndex + span[0], state.rankIndex + span[1]) - } - if (group.startsWith('w_')) { - rank.fill(2, state.rankIndex + span[0], state.rankIndex + span[1]) - } - }) + let dynamic = Trie.#dynamicMatch(match, separator) + let paramValues = state.paramValues.slice() - paramValues.push(...match.slice(1)) + paramValues.push(...dynamic.paramValues) + + let rank = state.rank.slice() + rank.push(...dynamic.rank) + stack.push({ index: [state.index[0] + 1, 0], trie, paramValues, rank, - rankIndex: - state.rankIndex + remaining.reduce((acc, segment) => acc + segment.length, 0), }) } } @@ -251,13 +264,15 @@ export class Trie { // will want to "skip" the protocol // todo: better explanation if (state.index[1] === 0 && state.trie.next) { - let length = query[state.index[0]].reduce((acc, segment) => acc + segment.length, 0) + let rank = state.rank.slice() + query[state.index[0]].forEach(() => { + rank.push('3') + }) stack.push({ index: [state.index[0] + 1, 0], trie: state.trie.next, paramValues: state.paramValues, - rank: state.rank.slice().fill(3, state.rankIndex, state.rankIndex + length), - rankIndex: state.rankIndex + length, + rank, }) } } @@ -272,15 +287,36 @@ export class Trie { // use capture group so that `split` includes the delimiters in the result .split(/(\{:\}|\{\*\})/) .map((part) => { - if (part === '{:}') return `(?${variablePattern})` - if (part === '{*}') return `(?${wildcardPattern})` - return RegExp_escape(part) + if (part === '{*}') return `(?${wildcardPattern})` + if (part === '{:}') return `(?${variablePattern})` + return `(?${RegExp_escape(part)})` }) .join('') return new RegExp(`^${source}$`, 'd') } + static #dynamicMatch( + match: RegExpExecArray, + separator: string, + ): { paramValues: Array; rank: Rank } { + let paramValues: Array = [] + let notSeparator = new RegExp(`[^${separator}]`, 'g') + let segmentRank = '' + Object.entries(match.indices?.groups ?? {}).forEach(([group, span]) => { + let type = group.split('_')[0] as keyof typeof RANK + let lexeme = match[0].slice(...span) + segmentRank += lexeme.replaceAll(notSeparator, RANK[type]) + if (type === 'variable' || type === 'wildcard') { + if (lexeme.length > 0) paramValues.push(lexeme) + } + }) + return { + paramValues, + rank: segmentRank.split(separator), + } + } + static #toParams(match: Match, paramValues: Array): Params { let params: Params = {} From d9c32a5415b3dfa7f3a741f2694b47574173d672 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 15 Dec 2025 15:16:55 -0500 Subject: [PATCH 27/54] fix outdated tests --- packages/route-pattern/src/lib2/part/variants.test.ts | 6 +++--- .../src/lib2/route-pattern/variants.test.ts | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/route-pattern/src/lib2/part/variants.test.ts b/packages/route-pattern/src/lib2/part/variants.test.ts index f7226ae9a8f..dafc1140278 100644 --- a/packages/route-pattern/src/lib2/part/variants.test.ts +++ b/packages/route-pattern/src/lib2/part/variants.test.ts @@ -8,9 +8,9 @@ describe('variants', () => { let source = 'api/(v:major(.:minor)/)run' let ast = parse(source) expect(variants(ast)).toEqual([ - { key: 'api/run', paramIndices: [] }, - { key: 'api/v{:}/run', paramIndices: [0] }, - { key: 'api/v{:}.{:}/run', paramIndices: [0, 1] }, + { key: 'api/run', paramNames: [] }, + { key: 'api/v{:}/run', paramNames: ['major'] }, + { key: 'api/v{:}.{:}/run', paramNames: ['major', 'minor'] }, ]) }) }) diff --git a/packages/route-pattern/src/lib2/route-pattern/variants.test.ts b/packages/route-pattern/src/lib2/route-pattern/variants.test.ts index 5d11754a1a5..b1f60309124 100644 --- a/packages/route-pattern/src/lib2/route-pattern/variants.test.ts +++ b/packages/route-pattern/src/lib2/route-pattern/variants.test.ts @@ -11,15 +11,15 @@ describe('variants', () => { expect(results).toEqual([ { key: [[], [], ['api', 'run']], - paramIndices: [[], [], []], + paramNames: [], }, { key: [[], [], ['api', 'v{:}', 'run']], - paramIndices: [[], [], [0]], + paramNames: ['major'], }, { key: [[], [], ['api', 'v{:}.{:}', 'run']], - paramIndices: [[], [], [0, 1]], + paramNames: ['major', 'minor'], }, ]) }) @@ -30,8 +30,8 @@ describe('variants', () => { let results = Array.from(variants(ast)) expect(results).toEqual([ { - key: [['https'], ['example', 'com'], ['users', '{:}']], - paramIndices: [[], [], [0]], + key: [['https'], ['com', 'example'], ['users', '{:}']], + paramNames: ['id'], }, ]) }) From d7c3fc6bfa04124a7835133cf475130e6cb09e0f Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 15 Dec 2025 15:25:38 -0500 Subject: [PATCH 28/54] Matcher.match returns params + data --- packages/route-pattern/src/lib2/matchers/matcher.ts | 3 ++- packages/route-pattern/src/lib2/matchers/trie.ts | 6 ++---- packages/route-pattern/src/lib2/params.ts | 1 + 3 files changed, 5 insertions(+), 5 deletions(-) create mode 100644 packages/route-pattern/src/lib2/params.ts diff --git a/packages/route-pattern/src/lib2/matchers/matcher.ts b/packages/route-pattern/src/lib2/matchers/matcher.ts index 106c14c7105..cec7520fd20 100644 --- a/packages/route-pattern/src/lib2/matchers/matcher.ts +++ b/packages/route-pattern/src/lib2/matchers/matcher.ts @@ -1,7 +1,8 @@ +import type { Params } from '../params.ts' import type * as RoutePattern from '../route-pattern/index.ts' export type Matcher = { add: (pattern: RoutePattern.AST, data: data) => void - match: (url: URL) => data | null + match: (url: URL) => { params: Params; data: data } | null size: number } diff --git a/packages/route-pattern/src/lib2/matchers/trie.ts b/packages/route-pattern/src/lib2/matchers/trie.ts index 8dc87fbd42e..435e0c24030 100644 --- a/packages/route-pattern/src/lib2/matchers/trie.ts +++ b/packages/route-pattern/src/lib2/matchers/trie.ts @@ -1,4 +1,5 @@ import { RegExp_escape } from '../es2025.ts' +import type { Params } from '../params.ts' import * as RoutePattern from '../route-pattern/index.ts' import type { Matcher } from './matcher.ts' @@ -18,8 +19,7 @@ export class TrieMatcher implements Matcher { best = match } } - // todo: also return params for this match - return best?.data ?? null + return best ?? null } get size() { @@ -64,8 +64,6 @@ type Match = { data: data } -type Params = Record - type SearchResult = { rank: Array params: Params diff --git a/packages/route-pattern/src/lib2/params.ts b/packages/route-pattern/src/lib2/params.ts new file mode 100644 index 00000000000..a4876200394 --- /dev/null +++ b/packages/route-pattern/src/lib2/params.ts @@ -0,0 +1 @@ +export type Params = Record From fa3d9bb1e5022f1970d60df66d198c203a52451c Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 15 Dec 2025 15:28:08 -0500 Subject: [PATCH 29/54] TrieMatcher doesn't expose `rank` --- packages/route-pattern/src/lib2/matchers/trie.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/route-pattern/src/lib2/matchers/trie.ts b/packages/route-pattern/src/lib2/matchers/trie.ts index 435e0c24030..9f50dad8369 100644 --- a/packages/route-pattern/src/lib2/matchers/trie.ts +++ b/packages/route-pattern/src/lib2/matchers/trie.ts @@ -19,7 +19,7 @@ export class TrieMatcher implements Matcher { best = match } } - return best ?? null + return best ? { params: best.params, data: best.data } : null } get size() { From 5a75823671c8537467b941964e7b9a2579614af3 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 15 Dec 2025 15:44:46 -0500 Subject: [PATCH 30/54] fix rank comparison --- packages/route-pattern/src/lib2/matchers/trie.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/route-pattern/src/lib2/matchers/trie.ts b/packages/route-pattern/src/lib2/matchers/trie.ts index 9f50dad8369..403493b0a68 100644 --- a/packages/route-pattern/src/lib2/matchers/trie.ts +++ b/packages/route-pattern/src/lib2/matchers/trie.ts @@ -42,10 +42,10 @@ function rankLessThan(a: Rank, b: Rank) { for (let i = 0; i < a.length; i++) { let segmentA = a[i] let segmentB = b[i] - if (segmentA < segmentB) return -1 - if (segmentA > segmentB) return 1 - return 0 + if (segmentA < segmentB) return true + if (segmentA > segmentB) return false } + return false } // Trie -------------------------------------------------------------------------------------------- From 6b91dbf3691264f3d65103f670a49123c0c544d0 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 15 Dec 2025 15:46:39 -0500 Subject: [PATCH 31/54] demo --- packages/route-pattern/src/lib2/demo.ts | 134 ++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 packages/route-pattern/src/lib2/demo.ts diff --git a/packages/route-pattern/src/lib2/demo.ts b/packages/route-pattern/src/lib2/demo.ts new file mode 100644 index 00000000000..e1cdb7905c2 --- /dev/null +++ b/packages/route-pattern/src/lib2/demo.ts @@ -0,0 +1,134 @@ +import { TrieMatcher } from './matchers/trie.ts' +import { parse } from './route-pattern/index.ts' + +// ============================================================================ +// Demo: TrieMatcher Pattern Matching with Ranking +// ============================================================================ +// This demo shows how the TrieMatcher handles multiple patterns, including +// cases where several patterns could match the same URL. The matcher uses +// a ranking system to pick the "best" match: +// - Static segments beat dynamic segments +// - Dynamic segments beat wildcards +// - More specific patterns beat less specific ones +// ============================================================================ + +type RouteData = { name: string; pattern: string } + +let matcher = new TrieMatcher() + +function addRoute(pattern: string, name: string) { + matcher.add(parse(pattern), { name, pattern }) +} + +// ---------------------------------------------------------------------------- +// Simple patterns +// ---------------------------------------------------------------------------- +addRoute('about', 'About') +addRoute('users', 'User List') +addRoute('users/:id', 'User Profile') +addRoute('users/:id/posts', 'User Posts') +addRoute('users/:id/posts/:postId', 'Single Post') + +// ---------------------------------------------------------------------------- +// Optional segments (generates multiple variants internally) +// ---------------------------------------------------------------------------- +addRoute('docs(/:lang)(/:version)', 'Docs') +addRoute('api(/v:major(.:minor))/status', 'API Status') + +// ---------------------------------------------------------------------------- +// Wildcards +// ---------------------------------------------------------------------------- +addRoute('files/*path', 'File Browser') +addRoute('assets/images/*rest', 'Image Assets') +addRoute('*catchall', 'Catch-All') + +// ---------------------------------------------------------------------------- +// Full URL patterns (protocol + hostname) +// ---------------------------------------------------------------------------- +addRoute('https://api.example.com/*path', 'API Subdomain') +addRoute('://admin.example.com/dashboard', 'Admin Dashboard') +addRoute('://:tenant.myapp.com/settings', 'Tenant Settings') + +// ---------------------------------------------------------------------------- +// Pathological / Advanced patterns +// ---------------------------------------------------------------------------- +addRoute('blog/:year-:month-:day/:slug', 'Blog Post by Date') +addRoute('org/:orgId/*path/settings', 'Org Settings Deep') +addRoute('assets/:name.png', 'PNG Asset') +addRoute('assets/:name.:ext', 'Any Asset') + +// ============================================================================ +// Test URLs +// ============================================================================ + +let testCases: Array<{ url: string; description: string }> = [ + // Simple static and dynamic + { url: 'https://example.com/about', description: 'Static path' }, + { url: 'https://example.com/users/123', description: 'Dynamic param' }, + { url: 'https://example.com/users/123/posts/456', description: 'Nested dynamic params' }, + + // Optional segments (demonstrating variant matching) + { url: 'https://example.com/docs', description: 'Docs without optionals' }, + { url: 'https://example.com/docs/en', description: 'Docs with lang' }, + { url: 'https://example.com/docs/en/v2', description: 'Docs with lang and version' }, + { url: 'https://example.com/api/status', description: 'API status without version' }, + { url: 'https://example.com/api/v2/status', description: 'API status with major version' }, + { url: 'https://example.com/api/v2.1/status', description: 'API status with major.minor' }, + + // Wildcards + { url: 'https://example.com/files/documents/report.pdf', description: 'Wildcard path' }, + { url: 'https://example.com/unknown/deeply/nested/path', description: 'Catch-all fallback' }, + + // Full URL patterns + { url: 'https://api.example.com/v1/users', description: 'API subdomain match' }, + { url: 'https://admin.example.com/dashboard', description: 'Admin dashboard (any protocol)' }, + { url: 'https://acme.myapp.com/settings', description: 'Tenant settings with dynamic subdomain' }, + + // Pathological patterns - demonstrating ranking + { url: 'https://example.com/blog/2024-12-15/hello-world', description: 'Blog date pattern' }, + { url: 'https://example.com/org/acme/projects/web/settings', description: 'Deep org settings' }, + + // Ranking demonstration: static vs dynamic in same segment + { url: 'https://example.com/assets/logo.png', description: 'PNG vs generic asset (PNG wins)' }, + { url: 'https://example.com/assets/photo.jpg', description: 'JPG matches generic asset pattern' }, + + // Ranking: more specific wildcards beat less specific + { + url: 'https://example.com/assets/images/photo/gallery', + description: 'Image wildcard vs catch-all', + }, +] + +// ============================================================================ +// Run the demo +// ============================================================================ + +console.log('='.repeat(80)) +console.log('TrieMatcher Demo') +console.log('='.repeat(80)) +console.log() +console.log(`Loaded ${matcher.size} patterns`) +console.log() + +for (let { url, description } of testCases) { + console.log('-'.repeat(80)) + console.log(`📍 ${description}`) + console.log(` URL: ${url}`) + + let result = matcher.match(new URL(url)) + + if (result) { + console.log(` ✅ Matched: "${result.data.name}"`) + console.log(` 🔗 Pattern: ${result.data.pattern}`) + if (Object.keys(result.params).length > 0) { + console.log(` 📦 Params:`, result.params) + } + } else { + console.log(` ❌ No match`) + } + console.log() +} + +console.log('='.repeat(80)) +console.log('Demo complete!') +console.log('='.repeat(80)) From 9f993b2b7b1a826668e54f1830e2f4b4075ef9a2 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 15 Dec 2025 15:58:51 -0500 Subject: [PATCH 32/54] matchAll --- .../src/lib2/matchers/matcher.ts | 1 + .../route-pattern/src/lib2/matchers/trie.ts | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/route-pattern/src/lib2/matchers/matcher.ts b/packages/route-pattern/src/lib2/matchers/matcher.ts index cec7520fd20..842bbc4f13b 100644 --- a/packages/route-pattern/src/lib2/matchers/matcher.ts +++ b/packages/route-pattern/src/lib2/matchers/matcher.ts @@ -4,5 +4,6 @@ import type * as RoutePattern from '../route-pattern/index.ts' export type Matcher = { add: (pattern: RoutePattern.AST, data: data) => void match: (url: URL) => { params: Params; data: data } | null + matchAll: (url: URL) => Array<{ params: Params; data: data }> size: number } diff --git a/packages/route-pattern/src/lib2/matchers/trie.ts b/packages/route-pattern/src/lib2/matchers/trie.ts index 403493b0a68..6d2dad4a7bc 100644 --- a/packages/route-pattern/src/lib2/matchers/trie.ts +++ b/packages/route-pattern/src/lib2/matchers/trie.ts @@ -22,6 +22,15 @@ export class TrieMatcher implements Matcher { return best ? { params: best.params, data: best.data } : null } + matchAll(url: URL) { + let matches = [] + for (let match of this.#trie.search(url)) { + matches.push(match) + } + matches.sort((a, b) => rankCompare(a.rank, b.rank)) + return matches + } + get size() { return this.#size } @@ -39,13 +48,17 @@ const RANK = { type Rank = Array function rankLessThan(a: Rank, b: Rank) { + return rankCompare(a, b) === -1 +} + +function rankCompare(a: Rank, b: Rank) { for (let i = 0; i < a.length; i++) { let segmentA = a[i] let segmentB = b[i] - if (segmentA < segmentB) return true - if (segmentA > segmentB) return false + if (segmentA < segmentB) return -1 + if (segmentA > segmentB) return 1 } - return false + return 0 } // Trie -------------------------------------------------------------------------------------------- From b8ebf3af44338e72517dee4883da6fb106ee2bb2 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 15 Dec 2025 15:59:00 -0500 Subject: [PATCH 33/54] matchAll demo --- packages/route-pattern/src/lib2/demo.ts | 95 +++++++++++-------------- 1 file changed, 40 insertions(+), 55 deletions(-) diff --git a/packages/route-pattern/src/lib2/demo.ts b/packages/route-pattern/src/lib2/demo.ts index e1cdb7905c2..c3bb1af8f1c 100644 --- a/packages/route-pattern/src/lib2/demo.ts +++ b/packages/route-pattern/src/lib2/demo.ts @@ -24,78 +24,54 @@ function addRoute(pattern: string, name: string) { // Simple patterns // ---------------------------------------------------------------------------- addRoute('about', 'About') -addRoute('users', 'User List') addRoute('users/:id', 'User Profile') -addRoute('users/:id/posts', 'User Posts') -addRoute('users/:id/posts/:postId', 'Single Post') // ---------------------------------------------------------------------------- // Optional segments (generates multiple variants internally) // ---------------------------------------------------------------------------- -addRoute('docs(/:lang)(/:version)', 'Docs') addRoute('api(/v:major(.:minor))/status', 'API Status') // ---------------------------------------------------------------------------- // Wildcards // ---------------------------------------------------------------------------- -addRoute('files/*path', 'File Browser') -addRoute('assets/images/*rest', 'Image Assets') addRoute('*catchall', 'Catch-All') -// ---------------------------------------------------------------------------- -// Full URL patterns (protocol + hostname) -// ---------------------------------------------------------------------------- -addRoute('https://api.example.com/*path', 'API Subdomain') -addRoute('://admin.example.com/dashboard', 'Admin Dashboard') -addRoute('://:tenant.myapp.com/settings', 'Tenant Settings') - // ---------------------------------------------------------------------------- // Pathological / Advanced patterns // ---------------------------------------------------------------------------- -addRoute('blog/:year-:month-:day/:slug', 'Blog Post by Date') -addRoute('org/:orgId/*path/settings', 'Org Settings Deep') addRoute('assets/:name.png', 'PNG Asset') addRoute('assets/:name.:ext', 'Any Asset') +// ---------------------------------------------------------------------------- +// Many overlapping patterns (for ranking demo) +// ---------------------------------------------------------------------------- +addRoute('files/report-2024/summary', 'Exact Match') +addRoute('files/report-:year/summary', 'Inline Variable') +addRoute('files/:folder/summary', 'Dynamic Folder') +addRoute('files/:folder/:page', 'Dynamic Folder + Page') +addRoute('files/*path', 'Files Wildcard') + // ============================================================================ // Test URLs // ============================================================================ let testCases: Array<{ url: string; description: string }> = [ - // Simple static and dynamic + // Simple examples { url: 'https://example.com/about', description: 'Static path' }, { url: 'https://example.com/users/123', description: 'Dynamic param' }, - { url: 'https://example.com/users/123/posts/456', description: 'Nested dynamic params' }, - - // Optional segments (demonstrating variant matching) - { url: 'https://example.com/docs', description: 'Docs without optionals' }, - { url: 'https://example.com/docs/en', description: 'Docs with lang' }, - { url: 'https://example.com/docs/en/v2', description: 'Docs with lang and version' }, - { url: 'https://example.com/api/status', description: 'API status without version' }, - { url: 'https://example.com/api/v2/status', description: 'API status with major version' }, - { url: 'https://example.com/api/v2.1/status', description: 'API status with major.minor' }, - - // Wildcards - { url: 'https://example.com/files/documents/report.pdf', description: 'Wildcard path' }, - { url: 'https://example.com/unknown/deeply/nested/path', description: 'Catch-all fallback' }, - - // Full URL patterns - { url: 'https://api.example.com/v1/users', description: 'API subdomain match' }, - { url: 'https://admin.example.com/dashboard', description: 'Admin dashboard (any protocol)' }, - { url: 'https://acme.myapp.com/settings', description: 'Tenant settings with dynamic subdomain' }, - - // Pathological patterns - demonstrating ranking - { url: 'https://example.com/blog/2024-12-15/hello-world', description: 'Blog date pattern' }, - { url: 'https://example.com/org/acme/projects/web/settings', description: 'Deep org settings' }, - - // Ranking demonstration: static vs dynamic in same segment - { url: 'https://example.com/assets/logo.png', description: 'PNG vs generic asset (PNG wins)' }, - { url: 'https://example.com/assets/photo.jpg', description: 'JPG matches generic asset pattern' }, - - // Ranking: more specific wildcards beat less specific + + // Interesting examples showing ranking (3+ matches each) { - url: 'https://example.com/assets/images/photo/gallery', - description: 'Image wildcard vs catch-all', + url: 'https://example.com/api/v2.1/status', + description: 'Optional segments: same pattern matches twice with different params', + }, + { + url: 'https://example.com/assets/logo.png', + description: 'Specificity: static suffix beats dynamic suffix', + }, + { + url: 'https://example.com/files/report-2024/summary', + description: 'Many matches: static > inline var > full var > wildcard', }, ] @@ -115,18 +91,27 @@ for (let { url, description } of testCases) { console.log(`📍 ${description}`) console.log(` URL: ${url}`) - let result = matcher.match(new URL(url)) + let results = matcher.matchAll(new URL(url)) - if (result) { - console.log(` ✅ Matched: "${result.data.name}"`) - console.log(` 🔗 Pattern: ${result.data.pattern}`) - if (Object.keys(result.params).length > 0) { - console.log(` 📦 Params:`, result.params) - } - } else { + if (results.length === 0) { console.log(` ❌ No match`) + console.log() + } else { + console.log( + ` Found ${results.length} match${results.length > 1 ? 'es' : ''} (ranked best to worst):`, + ) + console.log() + results.forEach((result, i) => { + let prefix = i === 0 ? '✅' : ' ' + console.log(` ${prefix} ${i + 1}. "${result.data.name}"`) + console.log(` Pattern: ${result.data.pattern}`) + let paramEntries = Object.entries(result.params).filter(([, v]) => v !== undefined) + if (paramEntries.length > 0) { + console.log(` Params:`, Object.fromEntries(paramEntries)) + } + console.log() + }) } - console.log() } console.log('='.repeat(80)) From f7e5f6aa72dbe1960a2fa99c5b5cd354659052c1 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Tue, 16 Dec 2025 10:59:56 -0500 Subject: [PATCH 34/54] add lib2 triematcher to comparison benchmark --- packages/route-pattern/bench/comparison.bench.ts | 6 ++++++ packages/route-pattern/src/lib2/index.ts | 1 + packages/route-pattern/src/lib2/matchers/index.ts | 2 ++ packages/route-pattern/src/lib2/matchers/trie.ts | 3 ++- 4 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 packages/route-pattern/src/lib2/matchers/index.ts diff --git a/packages/route-pattern/bench/comparison.bench.ts b/packages/route-pattern/bench/comparison.bench.ts index 3ac09f56501..db28362864b 100644 --- a/packages/route-pattern/bench/comparison.bench.ts +++ b/packages/route-pattern/bench/comparison.bench.ts @@ -11,6 +11,7 @@ import { bench, describe } from 'vitest' import FindMyWay from 'find-my-way' import { match } from 'path-to-regexp' import { ArrayMatcher, TrieMatcher } from '../src' +import { TrieMatcher as TrieMatcher2 } from '../src/lib2' type Syntax = 'route-pattern' | 'find-my-way' | 'path-to-regexp' @@ -24,6 +25,11 @@ const matchers: Array<{ syntax: Syntax createMatcher: () => Matcher }> = [ + { + name: 'route-pattern/lib2/trie', + syntax: 'route-pattern', + createMatcher: () => new TrieMatcher2(), + }, { name: 'route-pattern/trie', syntax: 'route-pattern', diff --git a/packages/route-pattern/src/lib2/index.ts b/packages/route-pattern/src/lib2/index.ts index e69de29bb2d..64eedd7f355 100644 --- a/packages/route-pattern/src/lib2/index.ts +++ b/packages/route-pattern/src/lib2/index.ts @@ -0,0 +1 @@ +export { TrieMatcher } from './matchers/' diff --git a/packages/route-pattern/src/lib2/matchers/index.ts b/packages/route-pattern/src/lib2/matchers/index.ts new file mode 100644 index 00000000000..b9acbfcaf24 --- /dev/null +++ b/packages/route-pattern/src/lib2/matchers/index.ts @@ -0,0 +1,2 @@ +export type { Matcher } from './matcher.ts' +export { TrieMatcher } from './trie.ts' diff --git a/packages/route-pattern/src/lib2/matchers/trie.ts b/packages/route-pattern/src/lib2/matchers/trie.ts index 6d2dad4a7bc..0d220b8f2ec 100644 --- a/packages/route-pattern/src/lib2/matchers/trie.ts +++ b/packages/route-pattern/src/lib2/matchers/trie.ts @@ -7,7 +7,8 @@ export class TrieMatcher implements Matcher { #trie: Trie = new Trie() #size: number = 0 - add(pattern: RoutePattern.AST, data: data) { + add(pattern: string | RoutePattern.AST, data: data) { + pattern = typeof pattern === 'string' ? RoutePattern.parse(pattern) : pattern this.#trie.insert(pattern, data) this.#size += 1 } From ab5e920075c575d247c5dc5f5a181ce47a86ec3f Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Tue, 16 Dec 2025 12:43:59 -0500 Subject: [PATCH 35/54] reorg --- packages/route-pattern/src/lib2/matchers/matcher.ts | 3 ++- packages/route-pattern/src/lib2/matchers/trie.ts | 3 +-- packages/route-pattern/src/lib2/params.ts | 1 - packages/route-pattern/src/lib2/route-pattern/ast.ts | 2 +- packages/route-pattern/src/lib2/route-pattern/parse.ts | 2 +- .../route-pattern/src/lib2/{ => route-pattern}/part/ast.ts | 0 .../route-pattern/src/lib2/{ => route-pattern}/part/index.ts | 0 .../src/lib2/{ => route-pattern}/part/parse.test.ts | 0 .../route-pattern/src/lib2/{ => route-pattern}/part/parse.ts | 0 .../src/lib2/{ => route-pattern}/part/to-regexp.test.ts | 0 .../src/lib2/{ => route-pattern}/part/to-regexp.ts | 2 +- .../src/lib2/{ => route-pattern}/part/variants.test.ts | 0 .../src/lib2/{ => route-pattern}/part/variants.ts | 0 packages/route-pattern/src/lib2/{ => route-pattern}/span.ts | 0 packages/route-pattern/src/lib2/route-pattern/split.ts | 2 +- packages/route-pattern/src/lib2/route-pattern/variants.ts | 2 +- 16 files changed, 8 insertions(+), 9 deletions(-) delete mode 100644 packages/route-pattern/src/lib2/params.ts rename packages/route-pattern/src/lib2/{ => route-pattern}/part/ast.ts (100%) rename packages/route-pattern/src/lib2/{ => route-pattern}/part/index.ts (100%) rename packages/route-pattern/src/lib2/{ => route-pattern}/part/parse.test.ts (100%) rename packages/route-pattern/src/lib2/{ => route-pattern}/part/parse.ts (100%) rename packages/route-pattern/src/lib2/{ => route-pattern}/part/to-regexp.test.ts (100%) rename packages/route-pattern/src/lib2/{ => route-pattern}/part/to-regexp.ts (95%) rename packages/route-pattern/src/lib2/{ => route-pattern}/part/variants.test.ts (100%) rename packages/route-pattern/src/lib2/{ => route-pattern}/part/variants.ts (100%) rename packages/route-pattern/src/lib2/{ => route-pattern}/span.ts (100%) diff --git a/packages/route-pattern/src/lib2/matchers/matcher.ts b/packages/route-pattern/src/lib2/matchers/matcher.ts index 842bbc4f13b..7bf261db56b 100644 --- a/packages/route-pattern/src/lib2/matchers/matcher.ts +++ b/packages/route-pattern/src/lib2/matchers/matcher.ts @@ -1,6 +1,7 @@ -import type { Params } from '../params.ts' import type * as RoutePattern from '../route-pattern/index.ts' +export type Params = Record + export type Matcher = { add: (pattern: RoutePattern.AST, data: data) => void match: (url: URL) => { params: Params; data: data } | null diff --git a/packages/route-pattern/src/lib2/matchers/trie.ts b/packages/route-pattern/src/lib2/matchers/trie.ts index 0d220b8f2ec..e339965d5e4 100644 --- a/packages/route-pattern/src/lib2/matchers/trie.ts +++ b/packages/route-pattern/src/lib2/matchers/trie.ts @@ -1,7 +1,6 @@ import { RegExp_escape } from '../es2025.ts' -import type { Params } from '../params.ts' import * as RoutePattern from '../route-pattern/index.ts' -import type { Matcher } from './matcher.ts' +import type { Matcher, Params } from './matcher.ts' export class TrieMatcher implements Matcher { #trie: Trie = new Trie() diff --git a/packages/route-pattern/src/lib2/params.ts b/packages/route-pattern/src/lib2/params.ts deleted file mode 100644 index a4876200394..00000000000 --- a/packages/route-pattern/src/lib2/params.ts +++ /dev/null @@ -1 +0,0 @@ -export type Params = Record diff --git a/packages/route-pattern/src/lib2/route-pattern/ast.ts b/packages/route-pattern/src/lib2/route-pattern/ast.ts index d3874cdede0..5243ff3136a 100644 --- a/packages/route-pattern/src/lib2/route-pattern/ast.ts +++ b/packages/route-pattern/src/lib2/route-pattern/ast.ts @@ -1,4 +1,4 @@ -import type * as Part from '../part/index.ts' +import type * as Part from './part/index.ts' export type AST = { protocol: Part.AST | undefined diff --git a/packages/route-pattern/src/lib2/route-pattern/parse.ts b/packages/route-pattern/src/lib2/route-pattern/parse.ts index 8aafe236c75..41401e0a860 100644 --- a/packages/route-pattern/src/lib2/route-pattern/parse.ts +++ b/packages/route-pattern/src/lib2/route-pattern/parse.ts @@ -1,6 +1,6 @@ import type { AST } from './ast.ts' import { split } from './split.ts' -import * as Part from '../part/index.ts' +import * as Part from './part/index.ts' export function parse(source: string): AST { let ast: AST = { diff --git a/packages/route-pattern/src/lib2/part/ast.ts b/packages/route-pattern/src/lib2/route-pattern/part/ast.ts similarity index 100% rename from packages/route-pattern/src/lib2/part/ast.ts rename to packages/route-pattern/src/lib2/route-pattern/part/ast.ts diff --git a/packages/route-pattern/src/lib2/part/index.ts b/packages/route-pattern/src/lib2/route-pattern/part/index.ts similarity index 100% rename from packages/route-pattern/src/lib2/part/index.ts rename to packages/route-pattern/src/lib2/route-pattern/part/index.ts diff --git a/packages/route-pattern/src/lib2/part/parse.test.ts b/packages/route-pattern/src/lib2/route-pattern/part/parse.test.ts similarity index 100% rename from packages/route-pattern/src/lib2/part/parse.test.ts rename to packages/route-pattern/src/lib2/route-pattern/part/parse.test.ts diff --git a/packages/route-pattern/src/lib2/part/parse.ts b/packages/route-pattern/src/lib2/route-pattern/part/parse.ts similarity index 100% rename from packages/route-pattern/src/lib2/part/parse.ts rename to packages/route-pattern/src/lib2/route-pattern/part/parse.ts diff --git a/packages/route-pattern/src/lib2/part/to-regexp.test.ts b/packages/route-pattern/src/lib2/route-pattern/part/to-regexp.test.ts similarity index 100% rename from packages/route-pattern/src/lib2/part/to-regexp.test.ts rename to packages/route-pattern/src/lib2/route-pattern/part/to-regexp.test.ts diff --git a/packages/route-pattern/src/lib2/part/to-regexp.ts b/packages/route-pattern/src/lib2/route-pattern/part/to-regexp.ts similarity index 95% rename from packages/route-pattern/src/lib2/part/to-regexp.ts rename to packages/route-pattern/src/lib2/route-pattern/part/to-regexp.ts index 57b0dd37f2a..f0971ae7b63 100644 --- a/packages/route-pattern/src/lib2/part/to-regexp.ts +++ b/packages/route-pattern/src/lib2/route-pattern/part/to-regexp.ts @@ -1,4 +1,4 @@ -import { RegExp_escape } from '../es2025.ts' +import { RegExp_escape } from '../../es2025.ts' import type { AST } from './ast.ts' export function toRegExp(ast: AST, paramValueRE: RegExp): RegExp { diff --git a/packages/route-pattern/src/lib2/part/variants.test.ts b/packages/route-pattern/src/lib2/route-pattern/part/variants.test.ts similarity index 100% rename from packages/route-pattern/src/lib2/part/variants.test.ts rename to packages/route-pattern/src/lib2/route-pattern/part/variants.test.ts diff --git a/packages/route-pattern/src/lib2/part/variants.ts b/packages/route-pattern/src/lib2/route-pattern/part/variants.ts similarity index 100% rename from packages/route-pattern/src/lib2/part/variants.ts rename to packages/route-pattern/src/lib2/route-pattern/part/variants.ts diff --git a/packages/route-pattern/src/lib2/span.ts b/packages/route-pattern/src/lib2/route-pattern/span.ts similarity index 100% rename from packages/route-pattern/src/lib2/span.ts rename to packages/route-pattern/src/lib2/route-pattern/span.ts diff --git a/packages/route-pattern/src/lib2/route-pattern/split.ts b/packages/route-pattern/src/lib2/route-pattern/split.ts index 996502cab09..917cf0ab962 100644 --- a/packages/route-pattern/src/lib2/route-pattern/split.ts +++ b/packages/route-pattern/src/lib2/route-pattern/split.ts @@ -1,4 +1,4 @@ -import type { Span } from '../span' +import type { Span } from './span' export interface SplitResult { protocol: Span | undefined diff --git a/packages/route-pattern/src/lib2/route-pattern/variants.ts b/packages/route-pattern/src/lib2/route-pattern/variants.ts index 150c26f482d..1b70b1fa4dc 100644 --- a/packages/route-pattern/src/lib2/route-pattern/variants.ts +++ b/packages/route-pattern/src/lib2/route-pattern/variants.ts @@ -1,5 +1,5 @@ import type { AST } from './ast.ts' -import * as Part from '../part/index.ts' +import * as Part from './part/index.ts' type Variant = { key: Array> From c05278dee4f32d95aa7d210d63848b9def1e4130 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Tue, 16 Dec 2025 12:44:05 -0500 Subject: [PATCH 36/54] remove part parse benchmark --- .../route-pattern/bench/parse-part.bench.ts | 52 ------------------- 1 file changed, 52 deletions(-) delete mode 100644 packages/route-pattern/bench/parse-part.bench.ts diff --git a/packages/route-pattern/bench/parse-part.bench.ts b/packages/route-pattern/bench/parse-part.bench.ts deleted file mode 100644 index b83653da147..00000000000 --- a/packages/route-pattern/bench/parse-part.bench.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * This isn't really apples-to-apples since `lib2` produces an AST - * that is designed for high speed generation of variants and trie branches - * whereas `lib` is a general-purpose AST. - * - * Just want to make sure `lib2` is as fast (or faster) than `lib`, - * but we won't see the full benefits of `lib2` until `lib` implements variant generation - * or until `lib2` has a trie to compare matching perf against `lib`. - */ -import { bench } from 'vitest' - -import { parsePart } from '../src/lib/parse.ts' -import { parse } from '../src/lib2/part/parse.ts' - -let patterns = [ - '/users/:id/posts', - '/products/:id', - '/products/:id/reviews', - '/docs/:category', - '/docs/:category/:page', - '/api/v1/products', - '/api/v1/products/:id', - '/api/v1/users/:userId', - '/posts/:id', - '/posts/:id/comments', - '/posts/:id/comments/:commentId', - '/categories/:category', - '/tags/:tag', - '/users/:userId/posts/:postId', - '/products/:id/reviews/:reviewId', - '/products/:category/:slug', - '/blog/:year/:month/:day/:slug', - '/api/v1/users/:userId/orders/:orderId', - '/docs/:lang/:category/:page', - '/api(/v:version)/orders', - '/api(/v:version)/orders/:orderId', - '/users/:id(.:format)', - '/posts/:slug(.html)', - '/docs(/:section)(/:page)', - '/products/:id(/reviews)', - '/assets/images/*path', - '/downloads/*', - '/files/*path', - '/static/*', -] - -bench('lib', () => { - patterns.forEach((pattern) => parsePart('', '/', pattern, 0, pattern.length)) -}) -bench('lib2', () => { - patterns.forEach((pattern) => parse(pattern)) -}) From 16024e21c50782823add88c8064a89edcae6ab4c Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Tue, 16 Dec 2025 12:57:08 -0500 Subject: [PATCH 37/54] fix ERR_UNSUPPORTED_DIR_IMPORT --- packages/route-pattern/src/lib2/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/route-pattern/src/lib2/index.ts b/packages/route-pattern/src/lib2/index.ts index 64eedd7f355..e8db6c76f16 100644 --- a/packages/route-pattern/src/lib2/index.ts +++ b/packages/route-pattern/src/lib2/index.ts @@ -1 +1 @@ -export { TrieMatcher } from './matchers/' +export { TrieMatcher } from './matchers/index.ts' From 1a69ff037da68825ed5780fbf9e7bd51a28fe088 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Tue, 16 Dec 2025 13:36:51 -0500 Subject: [PATCH 38/54] fix optional matching when begin index = 0 --- packages/route-pattern/src/lib2/route-pattern/part/parse.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/route-pattern/src/lib2/route-pattern/part/parse.ts b/packages/route-pattern/src/lib2/route-pattern/part/parse.ts index 34ee8a51a86..b52082acc0e 100644 --- a/packages/route-pattern/src/lib2/route-pattern/part/parse.ts +++ b/packages/route-pattern/src/lib2/route-pattern/part/parse.ts @@ -37,7 +37,7 @@ export function parse(source: string, span?: Span): AST { // optional end if (char === ')') { let begin = optionalStack.pop() - if (!begin) { + if (begin === undefined) { throw new Error(`unmatched ) at ${i}`) } ast.optionals.set(begin, ast.tokens.length) From 6e5d2f3f6f6a977a25f3e782a4a9f5e5928a4922 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Tue, 16 Dec 2025 13:37:23 -0500 Subject: [PATCH 39/54] join --- .../src/lib2/route-pattern/index.ts | 1 + .../src/lib2/route-pattern/join.test.ts | 262 ++++++++++++++++++ .../src/lib2/route-pattern/join.ts | 61 ++++ 3 files changed, 324 insertions(+) create mode 100644 packages/route-pattern/src/lib2/route-pattern/join.test.ts create mode 100644 packages/route-pattern/src/lib2/route-pattern/join.ts diff --git a/packages/route-pattern/src/lib2/route-pattern/index.ts b/packages/route-pattern/src/lib2/route-pattern/index.ts index 432ab1c1ff2..3238526edaa 100644 --- a/packages/route-pattern/src/lib2/route-pattern/index.ts +++ b/packages/route-pattern/src/lib2/route-pattern/index.ts @@ -1,3 +1,4 @@ export type { AST } from './ast.ts' +export { join } from './join.ts' export { parse } from './parse.ts' export { variants } from './variants.ts' diff --git a/packages/route-pattern/src/lib2/route-pattern/join.test.ts b/packages/route-pattern/src/lib2/route-pattern/join.test.ts new file mode 100644 index 00000000000..1e3dcff6a77 --- /dev/null +++ b/packages/route-pattern/src/lib2/route-pattern/join.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it } from 'vitest' + +import { join } from './join.ts' +import { parse } from './parse.ts' + +describe('join', () => { + describe('protocol', () => { + it('uses b.protocol when defined', () => { + let a = parse('http://example.com/path') + let b = parse('https://other.com/') + let result = join(a, b) + expect(result.protocol).toEqual({ + tokens: [{ type: 'text', text: 'https' }], + paramNames: [], + optionals: new Map(), + }) + }) + + it('falls back to a.protocol when b.protocol is undefined', () => { + let a = parse('https://example.com/path') + let b = parse('/other') + let result = join(a, b) + expect(result.protocol).toEqual({ + tokens: [{ type: 'text', text: 'https' }], + paramNames: [], + optionals: new Map(), + }) + }) + + it('is undefined when both are undefined', () => { + let a = parse('/path') + let b = parse('/other') + let result = join(a, b) + expect(result.protocol).toBeUndefined() + }) + }) + + describe('hostname', () => { + it('uses b.hostname when defined', () => { + let a = parse('https://example.com/path') + let b = parse('https://other.com/') + let result = join(a, b) + expect(result.hostname).toEqual({ + tokens: [{ type: 'text', text: 'other.com' }], + paramNames: [], + optionals: new Map(), + }) + }) + + it('falls back to a.hostname when b.hostname is undefined', () => { + let a = parse('https://example.com/path') + let b = parse('/other') + let result = join(a, b) + expect(result.hostname).toEqual({ + tokens: [{ type: 'text', text: 'example.com' }], + paramNames: [], + optionals: new Map(), + }) + }) + }) + + describe('port', () => { + it('uses b.port when defined', () => { + let a = parse('https://example.com:8080/path') + let b = parse('https://other.com:3000/') + let result = join(a, b) + expect(result.port).toBe('3000') + }) + + it('falls back to a.port when b.port is undefined', () => { + let a = parse('https://example.com:8080/path') + let b = parse('/other') + let result = join(a, b) + expect(result.port).toBe('8080') + }) + }) + + describe('pathname', () => { + it('joins two pathnames with slash when neither has one', () => { + let a = parse('users') + let b = parse(':id') + let result = join(a, b) + expect(result.pathname).toEqual({ + tokens: [ + { type: 'text', text: 'users' }, + { type: 'text', text: '/' }, + { type: ':', nameIndex: 0 }, + ], + paramNames: ['id'], + optionals: new Map(), + }) + }) + + it('does not add slash when a ends with slash', () => { + let a = parse('users/') + let b = parse(':id') + let result = join(a, b) + expect(result.pathname).toEqual({ + tokens: [ + { type: 'text', text: 'users/' }, + { type: ':', nameIndex: 0 }, + ], + paramNames: ['id'], + optionals: new Map(), + }) + }) + + it('does not add slash when b begins with slash', () => { + let a = parse('users') + let b = parse('/:id') + let result = join(a, b) + expect(result.pathname).toEqual({ + tokens: [ + { type: 'text', text: 'users' }, + { type: 'text', text: '/' }, + { type: ':', nameIndex: 0 }, + ], + paramNames: ['id'], + optionals: new Map(), + }) + }) + + it('does not add slash when both have slashes', () => { + let a = parse('users/') + let b = parse('/:id') + let result = join(a, b) + expect(result.pathname).toEqual({ + tokens: [ + { type: 'text', text: 'users/' }, + { type: ':', nameIndex: 0 }, + ], + paramNames: ['id'], + optionals: new Map(), + }) + }) + + it('returns b when a.pathname is undefined', () => { + let a = parse('https://example.com') + a.pathname = undefined + let b = parse('users') + let result = join(a, b) + expect(result.pathname).toEqual({ + tokens: [{ type: 'text', text: 'users' }], + paramNames: [], + optionals: new Map(), + }) + }) + + it('returns a when b.pathname is undefined', () => { + let a = parse('users') + let b = parse('https://example.com') + b.pathname = undefined + let result = join(a, b) + expect(result.pathname).toEqual({ + tokens: [{ type: 'text', text: 'users' }], + paramNames: [], + optionals: new Map(), + }) + }) + + it('returns undefined when both pathnames are undefined', () => { + let a = parse('https://example.com') + a.pathname = undefined + let b = parse('https://other.com') + b.pathname = undefined + let result = join(a, b) + expect(result.pathname).toBeUndefined() + }) + }) + + describe('params merging', () => { + it('merges paramNames from both pathnames', () => { + let a = parse(':org/:repo') + let b = parse('issues/:id') + let result = join(a, b) + expect(result.pathname?.paramNames).toEqual(['org', 'repo', 'id']) + }) + + it('updates nameIndex for b tokens', () => { + let a = parse(':org/:repo') + let b = parse(':branch/:file') + let result = join(a, b) + expect(result.pathname?.tokens).toEqual([ + { type: ':', nameIndex: 0 }, + { type: 'text', text: '/' }, + { type: ':', nameIndex: 1 }, + { type: 'text', text: '/' }, + { type: ':', nameIndex: 2 }, + { type: 'text', text: '/' }, + { type: ':', nameIndex: 3 }, + ]) + expect(result.pathname?.paramNames).toEqual(['org', 'repo', 'branch', 'file']) + }) + }) + + describe('optionals merging', () => { + it('preserves optionals from a', () => { + let a = parse('users(/:id)') + let b = parse('edit') + let result = join(a, b) + expect(result.pathname?.optionals).toEqual(new Map([[1, 4]])) + }) + + it('offsets optionals from b', () => { + let a = parse('users') + let b = parse('(/:id)/edit') + let result = join(a, b) + expect(result.pathname?.optionals).toEqual(new Map([[1, 4]])) + }) + + it('merges optionals from both with correct offsets', () => { + let a = parse('users(/:id)') + let b = parse('(/:action)/confirm') + let result = join(a, b) + expect(result.pathname?.optionals).toEqual( + new Map([ + [1, 4], + [5, 8], + ]), + ) + }) + + it('does not add offset for slash when a ends with slash', () => { + let a = parse('users/') + let b = parse('(/:id)/edit') + let result = join(a, b) + // a has 1 token, no slash added = offset 1 + // b's optionals were at (0, 3), should be at (1, 4) + expect(result.pathname?.optionals).toEqual(new Map([[1, 4]])) + }) + }) + + describe('search', () => { + it('uses b.search', () => { + let a = parse('/path?a=1') + let b = parse('/other?b=2') + let result = join(a, b) + expect(result.search).toBe('b=2') + }) + }) + + describe('integration', () => { + it('joins base URL with relative path', () => { + let base = parse('https://api.example.com:8080/v1') + let path = parse('users/:id/posts') + let result = join(base, path) + + expect(result.protocol).toEqual({ + tokens: [{ type: 'text', text: 'https' }], + paramNames: [], + optionals: new Map(), + }) + expect(result.hostname).toEqual({ + tokens: [{ type: 'text', text: 'api.example.com' }], + paramNames: [], + optionals: new Map(), + }) + expect(result.port).toBe('8080') + expect(result.pathname?.paramNames).toEqual(['id']) + }) + }) +}) diff --git a/packages/route-pattern/src/lib2/route-pattern/join.ts b/packages/route-pattern/src/lib2/route-pattern/join.ts new file mode 100644 index 00000000000..d902713e722 --- /dev/null +++ b/packages/route-pattern/src/lib2/route-pattern/join.ts @@ -0,0 +1,61 @@ +import type { AST } from './ast.ts' +import type * as Part from './part/index.ts' + +export function join(a: AST, b: AST): AST { + return { + protocol: b.protocol ?? a.protocol, + hostname: b.hostname ?? a.hostname, + port: b.port ?? a.port, + pathname: joinPathname(a.pathname, b.pathname), + search: b.search, // todo + } +} + +function joinPathname(a: Part.AST | undefined, b: Part.AST | undefined): Part.AST | undefined { + if (a === undefined) return b + if (b === undefined) return a + + let aLastIndex = a.tokens.findLastIndex((token) => token.type !== '(' && token.type !== ')') + let aLast = aLastIndex === -1 ? undefined : a.tokens[aLastIndex] + let aEndsWithSlash = aLast?.type === 'text' && aLast.text.at(-1) === '/' + + let bFirstIndex = b.tokens.findIndex((token) => token.type !== '(' && token.type !== ')') + let bFirst = bFirstIndex === -1 ? undefined : b.tokens[bFirstIndex] + let bBeginsWithSlash = bFirst?.type === 'text' && bFirst.text[0] === '/' + + let needsSlash = !aEndsWithSlash && !bBeginsWithSlash + + // tokens + let tokens = a.tokens.slice() + if (needsSlash) tokens.push({ type: 'text', text: '/' }) + b.tokens.forEach((token) => { + if (token.type === ':') { + token = structuredClone(token) + token.nameIndex += a.paramNames.length + tokens.push(token) + return + } + if (token.type === '*') { + token = structuredClone(token) + if (token.nameIndex) token.nameIndex += a.paramNames.length + tokens.push(token) + return + } + tokens.push(token) + }) + + // paramNames + let paramNames = a.paramNames.slice() + b.paramNames.forEach((name) => paramNames.push(name)) + + // optionals + let tokenOffset = a.tokens.length + (needsSlash ? 1 : 0) + let optionals = new Map(a.optionals) + b.optionals.forEach((end, begin) => optionals.set(tokenOffset + begin, tokenOffset + end)) + + return { + tokens, + paramNames, + optionals, + } +} From 283902da937e76749ac0d0aa5bfdd48d01aec20d Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Tue, 16 Dec 2025 13:47:38 -0500 Subject: [PATCH 40/54] shallow copy --- packages/route-pattern/src/lib2/route-pattern/join.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/route-pattern/src/lib2/route-pattern/join.ts b/packages/route-pattern/src/lib2/route-pattern/join.ts index d902713e722..050717abdb0 100644 --- a/packages/route-pattern/src/lib2/route-pattern/join.ts +++ b/packages/route-pattern/src/lib2/route-pattern/join.ts @@ -30,13 +30,14 @@ function joinPathname(a: Part.AST | undefined, b: Part.AST | undefined): Part.AS if (needsSlash) tokens.push({ type: 'text', text: '/' }) b.tokens.forEach((token) => { if (token.type === ':') { - token = structuredClone(token) - token.nameIndex += a.paramNames.length - tokens.push(token) + tokens.push({ + ...token, + nameIndex: token.nameIndex + a.paramNames.length, + }) return } if (token.type === '*') { - token = structuredClone(token) + token = { ...token } if (token.nameIndex) token.nameIndex += a.paramNames.length tokens.push(token) return From 106738082fbbd339828955378bb369fd047f62e8 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Tue, 16 Dec 2025 13:51:19 -0500 Subject: [PATCH 41/54] nameIndex: undefined (value optional, not key optional) --- packages/route-pattern/src/lib2/route-pattern/join.ts | 7 ++++--- packages/route-pattern/src/lib2/route-pattern/part/ast.ts | 2 +- .../route-pattern/src/lib2/route-pattern/part/parse.ts | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/route-pattern/src/lib2/route-pattern/join.ts b/packages/route-pattern/src/lib2/route-pattern/join.ts index 050717abdb0..8b61f2b8c3e 100644 --- a/packages/route-pattern/src/lib2/route-pattern/join.ts +++ b/packages/route-pattern/src/lib2/route-pattern/join.ts @@ -37,9 +37,10 @@ function joinPathname(a: Part.AST | undefined, b: Part.AST | undefined): Part.AS return } if (token.type === '*') { - token = { ...token } - if (token.nameIndex) token.nameIndex += a.paramNames.length - tokens.push(token) + tokens.push({ + ...token, + nameIndex: token.nameIndex ? token.nameIndex + a.paramNames.length : undefined, + }) return } tokens.push(token) diff --git a/packages/route-pattern/src/lib2/route-pattern/part/ast.ts b/packages/route-pattern/src/lib2/route-pattern/part/ast.ts index 7c90aaf5999..54f1960a58e 100644 --- a/packages/route-pattern/src/lib2/route-pattern/part/ast.ts +++ b/packages/route-pattern/src/lib2/route-pattern/part/ast.ts @@ -8,4 +8,4 @@ type Token = | { type: 'text'; text: string } | { type: '(' | ')' } | { type: ':'; nameIndex: number } - | { type: '*'; nameIndex?: number } + | { type: '*'; nameIndex: number | undefined } diff --git a/packages/route-pattern/src/lib2/route-pattern/part/parse.ts b/packages/route-pattern/src/lib2/route-pattern/part/parse.ts index b52082acc0e..553281d4530 100644 --- a/packages/route-pattern/src/lib2/route-pattern/part/parse.ts +++ b/packages/route-pattern/src/lib2/route-pattern/part/parse.ts @@ -68,7 +68,7 @@ export function parse(source: string, span?: Span): AST { ast.paramNames.push(name) i += name.length } else { - ast.tokens.push({ type: '*' }) + ast.tokens.push({ type: '*', nameIndex: undefined }) } continue } From b05dce5aaec7db0cdf51b1f156c8883a5a224e26 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 17 Dec 2025 14:22:28 -0500 Subject: [PATCH 42/54] refactor + port matching --- packages/route-pattern/src/lib2/demo.ts | 2 +- .../route-pattern/src/lib2/matchers/index.ts | 2 +- .../route-pattern/src/lib2/matchers/trie.ts | 344 ------------------ .../src/lib2/matchers/trie/index.ts | 1 + .../src/lib2/matchers/trie/matcher.ts | 37 ++ .../src/lib2/matchers/trie/rank.ts | 16 + .../src/lib2/matchers/{ => trie}/trie.test.ts | 101 +++-- .../src/lib2/matchers/trie/trie.ts | 289 +++++++++++++++ .../src/lib2/route-pattern/index.ts | 1 + .../src/lib2/route-pattern/param-names.ts | 9 + .../lib2/route-pattern/part/variants.test.ts | 6 +- .../src/lib2/route-pattern/part/variants.ts | 8 +- .../src/lib2/route-pattern/variants.test.ts | 16 +- .../src/lib2/route-pattern/variants.ts | 41 ++- 14 files changed, 471 insertions(+), 402 deletions(-) delete mode 100644 packages/route-pattern/src/lib2/matchers/trie.ts create mode 100644 packages/route-pattern/src/lib2/matchers/trie/index.ts create mode 100644 packages/route-pattern/src/lib2/matchers/trie/matcher.ts create mode 100644 packages/route-pattern/src/lib2/matchers/trie/rank.ts rename packages/route-pattern/src/lib2/matchers/{ => trie}/trie.test.ts (85%) create mode 100644 packages/route-pattern/src/lib2/matchers/trie/trie.ts create mode 100644 packages/route-pattern/src/lib2/route-pattern/param-names.ts diff --git a/packages/route-pattern/src/lib2/demo.ts b/packages/route-pattern/src/lib2/demo.ts index c3bb1af8f1c..d0d6a0651a9 100644 --- a/packages/route-pattern/src/lib2/demo.ts +++ b/packages/route-pattern/src/lib2/demo.ts @@ -1,4 +1,4 @@ -import { TrieMatcher } from './matchers/trie.ts' +import { TrieMatcher } from './matchers/trie/index.ts' import { parse } from './route-pattern/index.ts' // ============================================================================ diff --git a/packages/route-pattern/src/lib2/matchers/index.ts b/packages/route-pattern/src/lib2/matchers/index.ts index b9acbfcaf24..5d4e4492eab 100644 --- a/packages/route-pattern/src/lib2/matchers/index.ts +++ b/packages/route-pattern/src/lib2/matchers/index.ts @@ -1,2 +1,2 @@ export type { Matcher } from './matcher.ts' -export { TrieMatcher } from './trie.ts' +export { TrieMatcher } from './trie/index.ts' diff --git a/packages/route-pattern/src/lib2/matchers/trie.ts b/packages/route-pattern/src/lib2/matchers/trie.ts deleted file mode 100644 index e339965d5e4..00000000000 --- a/packages/route-pattern/src/lib2/matchers/trie.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { RegExp_escape } from '../es2025.ts' -import * as RoutePattern from '../route-pattern/index.ts' -import type { Matcher, Params } from './matcher.ts' - -export class TrieMatcher implements Matcher { - #trie: Trie = new Trie() - #size: number = 0 - - add(pattern: string | RoutePattern.AST, data: data) { - pattern = typeof pattern === 'string' ? RoutePattern.parse(pattern) : pattern - this.#trie.insert(pattern, data) - this.#size += 1 - } - - match(url: URL) { - let best: SearchResult | null = null - for (let match of this.#trie.search(url)) { - if (best === null || rankLessThan(match.rank, best.rank)) { - best = match - } - } - return best ? { params: best.params, data: best.data } : null - } - - matchAll(url: URL) { - let matches = [] - for (let match of this.#trie.search(url)) { - matches.push(match) - } - matches.sort((a, b) => rankCompare(a.rank, b.rank)) - return matches - } - - get size() { - return this.#size - } -} - -// Rank -------------------------------------------------------------------------------------------- - -const RANK = { - skip: '3', - wildcard: '2', - variable: '1', - static: '0', -} - -type Rank = Array - -function rankLessThan(a: Rank, b: Rank) { - return rankCompare(a, b) === -1 -} - -function rankCompare(a: Rank, b: Rank) { - for (let i = 0; i < a.length; i++) { - let segmentA = a[i] - let segmentB = b[i] - if (segmentA < segmentB) return -1 - if (segmentA > segmentB) return 1 - } - return 0 -} - -// Trie -------------------------------------------------------------------------------------------- - -// todo: NOT_SEPARATORS: Array ? -const SEPARATORS = ['', '.', '/'] - -type TrieIndex = [partIndex: number, segmentIndex: number] - -type Match = { - paramNames: { - /** In the same order as they appear in their variant */ - included: Array - excluded: Array - } - data: data -} - -type SearchResult = { - rank: Array - params: Params - data: data -} - -export class Trie { - static: Record | undefined> = {} - variable: Map }> = new Map() - wildcard: Map }> = new Map() - next?: Trie - match?: Match - - insert(pattern: RoutePattern.AST, data: data) { - let patternParamNames = [ - ...(pattern.protocol?.paramNames ?? []), - ...(pattern.hostname?.paramNames ?? []), - ...(pattern.pathname?.paramNames ?? []), - ] - - for (let variant of RoutePattern.variants(pattern)) { - let match: Match = { - paramNames: { - included: variant.paramNames, - excluded: patternParamNames.filter((name) => !variant.paramNames.includes(name)), - }, - data, - } - - let trie: Trie = this - let index: TrieIndex = [0, 0] - while (true) { - if (index[0] === variant.key.length) { - // todo: what if `match` already exists (duplicate / conflict)? - trie.match = match - break - } - - let part = variant.key[index[0]] - if (index[1] >= part.length) { - if (!trie.next) trie.next = new Trie() - trie = trie.next - index[0] += 1 - index[1] = 0 - continue - } - - let segment = part[index[1]] - - let hasWildcard = segment.includes('{*}') - if (hasWildcard) { - let segments = part.slice(index[1]) - let key = segments.join(SEPARATORS[index[0]]) - let next = trie.wildcard.get(key) - if (!next) { - let regexp = Trie.#keyToRegExp(key, SEPARATORS[index[0]]) - next = { regexp, trie: new Trie() } - trie.wildcard.set(key, next) - } - trie = next.trie - index[0] += 1 - index[1] = 0 - continue - } - - let hasVariable = segment.includes('{:}') - if (hasVariable) { - let next = trie.variable.get(segment) - if (!next) { - let regexp = Trie.#keyToRegExp(segment, SEPARATORS[index[0]]) - next = { regexp, trie: new Trie() } - trie.variable.set(segment, next) - } - trie = next.trie - index[1] += 1 - continue - } - - let next = trie.static[segment] - if (!next) { - next = new Trie() - trie.static[segment] = next - } - trie = next - index[1] += 1 - } - } - } - - *search(url: URL): Generator> { - let protocol = url.protocol.slice(0, -1) - let hostname = url.hostname.split('.').reverse() - let pathname = url.pathname.slice(1).split('/') - let query = [[protocol], hostname, pathname] - - type State = { - index: TrieIndex - trie: Trie - paramValues: Array - rank: Rank - } - let stack: Array = [ - { - index: [0, 0], - trie: this, - paramValues: [], - rank: [], - }, - ] - - while (stack.length > 0) { - let state = stack.pop()! - - if (state.index[0] === query.length) { - let { match } = state.trie - if (match) { - yield { - rank: state.rank, - params: Trie.#toParams(match, state.paramValues), - data: match.data, - } - } - continue - } - - let part = query[state.index[0]] - if (state.index[1] === part.length) { - if (state.trie.next) { - state.index[0] += 1 - state.index[1] = 0 - state.trie = state.trie.next - stack.push(state) - } - continue - } - - let segment = part[state.index[1]] - - let staticMatch = state.trie.static[segment] - if (staticMatch) { - let rank = state.rank.slice() - rank.push(RANK.static) - stack.push({ - index: [state.index[0], state.index[1] + 1], - trie: staticMatch, - paramValues: state.paramValues, - rank, - }) - } - - let separator = SEPARATORS[state.index[0]] - - for (let { regexp, trie } of state.trie.variable.values()) { - let match = regexp.exec(segment) - if (match) { - let dynamic = Trie.#dynamicMatch(match, separator) - - let paramValues = state.paramValues.slice() - paramValues.push(...dynamic.paramValues) - - let rank = state.rank.slice() - rank.push(...dynamic.rank) - - stack.push({ - index: [state.index[0], state.index[1] + 1], - trie, - paramValues, - rank, - }) - } - } - - for (let { regexp, trie } of state.trie.wildcard.values()) { - let remaining = part.slice(state.index[1]) - let match = regexp.exec(remaining.join(separator)) - if (match) { - let dynamic = Trie.#dynamicMatch(match, separator) - - let paramValues = state.paramValues.slice() - paramValues.push(...dynamic.paramValues) - - let rank = state.rank.slice() - rank.push(...dynamic.rank) - - stack.push({ - index: [state.index[0] + 1, 0], - trie, - paramValues, - rank, - }) - } - } - - // Consider skipping an entire part - // For example, a pattern like `://remix.run/about` - // will want to "skip" the protocol - // todo: better explanation - if (state.index[1] === 0 && state.trie.next) { - let rank = state.rank.slice() - query[state.index[0]].forEach(() => { - rank.push('3') - }) - stack.push({ - index: [state.index[0] + 1, 0], - trie: state.trie.next, - paramValues: state.paramValues, - rank, - }) - } - } - } - - static #keyToRegExp(key: string, separator: string): RegExp { - let variablePattern = `[^${RegExp_escape(separator)}]*` - let wildcardPattern = '.*' - - let i = 0 - let source = key - // use capture group so that `split` includes the delimiters in the result - .split(/(\{:\}|\{\*\})/) - .map((part) => { - if (part === '{*}') return `(?${wildcardPattern})` - if (part === '{:}') return `(?${variablePattern})` - return `(?${RegExp_escape(part)})` - }) - .join('') - - return new RegExp(`^${source}$`, 'd') - } - - static #dynamicMatch( - match: RegExpExecArray, - separator: string, - ): { paramValues: Array; rank: Rank } { - let paramValues: Array = [] - let notSeparator = new RegExp(`[^${separator}]`, 'g') - let segmentRank = '' - Object.entries(match.indices?.groups ?? {}).forEach(([group, span]) => { - let type = group.split('_')[0] as keyof typeof RANK - let lexeme = match[0].slice(...span) - segmentRank += lexeme.replaceAll(notSeparator, RANK[type]) - if (type === 'variable' || type === 'wildcard') { - if (lexeme.length > 0) paramValues.push(lexeme) - } - }) - return { - paramValues, - rank: segmentRank.split(separator), - } - } - - static #toParams(match: Match, paramValues: Array): Params { - let params: Params = {} - - match.paramNames.excluded.forEach((name) => { - params[name] = undefined - }) - - match.paramNames.included.forEach((name, i) => { - params[name] = paramValues[i] - }) - - return params - } -} diff --git a/packages/route-pattern/src/lib2/matchers/trie/index.ts b/packages/route-pattern/src/lib2/matchers/trie/index.ts new file mode 100644 index 00000000000..abe12f7dc1c --- /dev/null +++ b/packages/route-pattern/src/lib2/matchers/trie/index.ts @@ -0,0 +1 @@ +export { TrieMatcher } from './matcher.ts' diff --git a/packages/route-pattern/src/lib2/matchers/trie/matcher.ts b/packages/route-pattern/src/lib2/matchers/trie/matcher.ts new file mode 100644 index 00000000000..c5067b68c0d --- /dev/null +++ b/packages/route-pattern/src/lib2/matchers/trie/matcher.ts @@ -0,0 +1,37 @@ +import * as RoutePattern from '../../route-pattern/index.ts' +import { Trie, type Match } from './trie.ts' +import * as Rank from './rank.ts' + +export class TrieMatcher { + #trie: Trie = new Trie() + #size: number = 0 + + add(pattern: string | RoutePattern.AST, data: data) { + pattern = typeof pattern === 'string' ? RoutePattern.parse(pattern) : pattern + this.#trie.insert(pattern, data) + this.#size += 1 + } + + match(url: URL) { + let best: Match | null = null + for (let match of this.#trie.search(url)) { + if (best === null || Rank.lessThan(match.rank, best.rank)) { + best = match + } + } + return best ? { params: best.params, data: best.data } : null + } + + matchAll(url: URL) { + let matches = [] + for (let match of this.#trie.search(url)) { + matches.push(match) + } + matches.sort((a, b) => Rank.compare(a.rank, b.rank)) + return matches + } + + get size() { + return this.#size + } +} diff --git a/packages/route-pattern/src/lib2/matchers/trie/rank.ts b/packages/route-pattern/src/lib2/matchers/trie/rank.ts new file mode 100644 index 00000000000..062bdffa50b --- /dev/null +++ b/packages/route-pattern/src/lib2/matchers/trie/rank.ts @@ -0,0 +1,16 @@ +type Rank = Array +export type Type = Rank + +export function lessThan(a: Rank, b: Rank): boolean { + return compare(a, b) === -1 +} + +export function compare(a: Rank, b: Rank): -1 | 0 | 1 { + for (let i = 0; i < a.length; i++) { + let segmentA = a[i] + let segmentB = b[i] + if (segmentA < segmentB) return -1 + if (segmentA > segmentB) return 1 + } + return 0 +} diff --git a/packages/route-pattern/src/lib2/matchers/trie.test.ts b/packages/route-pattern/src/lib2/matchers/trie/trie.test.ts similarity index 85% rename from packages/route-pattern/src/lib2/matchers/trie.test.ts rename to packages/route-pattern/src/lib2/matchers/trie/trie.test.ts index 8200f2d34ac..d8376f893ed 100644 --- a/packages/route-pattern/src/lib2/matchers/trie.test.ts +++ b/packages/route-pattern/src/lib2/matchers/trie/trie.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it } from 'vitest' -import { parse } from '../route-pattern/parse.ts' -import type * as RoutePattern from '../route-pattern/index.ts' +import { parse } from '../../route-pattern/parse.ts' +import type * as RoutePattern from '../../route-pattern/index.ts' import { Trie } from './trie.ts' function searchAll(trie: Trie, url: URL) { return [...trie.search(url)] } -describe('trie', () => { +describe('Trie', () => { describe('constructor', () => { it('creates a trie with empty nodes', () => { let trie = new Trie() @@ -16,7 +16,7 @@ describe('trie', () => { expect(trie.variable).toEqual(new Map()) expect(trie.wildcard).toEqual(new Map()) expect(trie.next).toBe(undefined) - expect(trie.match).toBe(undefined) + expect(trie.value).toBe(undefined) }) }) @@ -26,9 +26,9 @@ describe('trie', () => { let pattern = parse('users/list') trie.insert(pattern, null) - // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) - expect(trie.next?.next?.static['users']).toBeTruthy() - expect(trie.next?.next?.static['users']?.static['list']).toBeTruthy() + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (port) -> next (pathname) + expect(trie.next?.next?.next?.static['users']).toBeTruthy() + expect(trie.next?.next?.next?.static['users']?.static['list']).toBeTruthy() }) it('inserts a pattern with dynamic segment', () => { @@ -36,9 +36,9 @@ describe('trie', () => { let pattern = parse('users/:id') trie.insert(pattern, null) - // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) - expect(trie.next?.next?.static['users']).toBeTruthy() - expect(trie.next?.next?.static['users']?.variable.has('{:}')).toBeTruthy() + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (port) -> next (pathname) + expect(trie.next?.next?.next?.static['users']).toBeTruthy() + expect(trie.next?.next?.next?.static['users']?.variable.has('{:}')).toBeTruthy() }) it('inserts a full URL pattern', () => { @@ -63,8 +63,8 @@ describe('trie', () => { let pattern = parse('api/(v:major(.:minor)/)run') trie.insert(pattern, null) - // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) - let pathnameLevel = trie.next?.next + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (port) -> next (pathname) + let pathnameLevel = trie.next?.next?.next // Should have multiple variants added to the trie // Variant 1: api/run @@ -88,8 +88,8 @@ describe('trie', () => { trie.insert(pattern2, null) trie.insert(pattern3, null) - // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) - let pathnameLevel = trie.next?.next + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (port) -> next (pathname) + let pathnameLevel = trie.next?.next?.next // Users branch expect(pathnameLevel?.static['users']).toBeTruthy() @@ -106,8 +106,8 @@ describe('trie', () => { let pattern = parse('files/*path') trie.insert(pattern, null) - // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) - let pathnameLevel = trie.next?.next + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (port) -> next (pathname) + let pathnameLevel = trie.next?.next?.next // Should have static 'files' segment expect(pathnameLevel?.static['files']).toBeTruthy() @@ -120,8 +120,8 @@ describe('trie', () => { let pattern = parse('assets/images/*file') trie.insert(pattern, null) - // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) - let pathnameLevel = trie.next?.next + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (port) -> next (pathname) + let pathnameLevel = trie.next?.next?.next expect(pathnameLevel?.static['assets']).toBeTruthy() expect(pathnameLevel?.static['assets']?.static['images']).toBeTruthy() @@ -133,8 +133,8 @@ describe('trie', () => { let pattern = parse('files/prefix-*rest') trie.insert(pattern, null) - // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) - let pathnameLevel = trie.next?.next + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (port) -> next (pathname) + let pathnameLevel = trie.next?.next?.next expect(pathnameLevel?.static['files']).toBeTruthy() // Wildcard key should include the prefix @@ -146,8 +146,8 @@ describe('trie', () => { let pattern = parse('api/*') trie.insert(pattern, null) - // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) - let pathnameLevel = trie.next?.next + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (port) -> next (pathname) + let pathnameLevel = trie.next?.next?.next expect(pathnameLevel?.static['api']).toBeTruthy() expect(pathnameLevel?.static['api']?.wildcard.has('{*}')).toBeTruthy() @@ -158,8 +158,8 @@ describe('trie', () => { let pattern = parse('files/*path/details') trie.insert(pattern, null) - // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) - let pathnameLevel = trie.next?.next + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (port) -> next (pathname) + let pathnameLevel = trie.next?.next?.next expect(pathnameLevel?.static['files']).toBeTruthy() // Wildcard key should include the segments after the wildcard @@ -171,8 +171,8 @@ describe('trie', () => { let pattern = parse('files/*path/foo/bar') trie.insert(pattern, null) - // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) - let pathnameLevel = trie.next?.next + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (port) -> next (pathname) + let pathnameLevel = trie.next?.next?.next expect(pathnameLevel?.static['files']).toBeTruthy() // Wildcard key should include all segments after the wildcard @@ -184,13 +184,48 @@ describe('trie', () => { let pattern = parse('files/*path/:id') trie.insert(pattern, null) - // Navigate to pathname level: root (protocol) -> next (hostname) -> next (pathname) - let pathnameLevel = trie.next?.next + // Navigate to pathname level: root (protocol) -> next (hostname) -> next (port) -> next (pathname) + let pathnameLevel = trie.next?.next?.next expect(pathnameLevel?.static['files']).toBeTruthy() // Wildcard key should include the dynamic segment after the wildcard expect(pathnameLevel?.static['files']?.wildcard.has('{*}/{:}')).toBeTruthy() }) + + it('stores value with paramNames and paramIndices', () => { + let trie = new Trie() + let pattern = parse('users/:id') + let data = { route: 'user-detail' } + trie.insert(pattern, data) + + // Navigate to the leaf node: protocol -> hostname -> port -> pathname (users) -> variable ({:}) -> port -> search + let pathnameLevel = trie.next?.next?.next + let usersLevel = pathnameLevel?.static['users'] + let variableLevel = usersLevel?.variable.get('{:}')?.trie + // After variable, we need to traverse to the end + let leaf = variableLevel?.next + expect(leaf?.value).toBeTruthy() + expect(leaf?.value?.data).toBe(data) + expect(leaf?.value?.paramNames).toEqual(['id']) + }) + + it('stores correct paramNames for multiple params', () => { + let trie = new Trie() + let pattern = parse('org/:orgId/repo/:repoId') + let data = { route: 'repo-detail' } + trie.insert(pattern, data) + + // Navigate through the trie to find the leaf + let pathnameLevel = trie.next?.next?.next + let orgLevel = pathnameLevel?.static['org'] + let orgIdLevel = orgLevel?.variable.get('{:}')?.trie + let repoLevel = orgIdLevel?.static['repo'] + let repoIdLevel = repoLevel?.variable.get('{:}')?.trie + let leaf = repoIdLevel?.next + + expect(leaf?.value).toBeTruthy() + expect(leaf?.value?.paramNames).toEqual(['orgId', 'repoId']) + }) }) describe('search', () => { @@ -341,6 +376,7 @@ describe('trie', () => { '3', // protocol: "https" (skipped: 3) '3', // hostname: "com" (skipped: 3) '3', // hostname: "example" (skipped: 3) + '3', // port: "" (skipped: 3) '0', // pathname: "users" (static: 0) '0', // pathname: "@admin" (static: 0) ], @@ -353,6 +389,7 @@ describe('trie', () => { '3', // protocol: "https" (skipped: 3) '3', // hostname: "com" (skipped: 3) '3', // hostname: "example" (skipped: 3) + '3', // port: "" (skipped: 3) '0', // pathname: "users" (static: 0) '011111', // pathname: "@admin" (dynamic: "011111") ], @@ -373,6 +410,7 @@ describe('trie', () => { '3', // protocol: "https" (skipped: 3) '3', // hostname: "com" (skipped: 3) '3', // hostname: "example" (skipped: 3) + '3', // port: "" (skipped: 3) '0', // pathname: "files" (static: 0) '00000022222', // pathname: "image-" (static) + "photo" (wildcard) '2222222', // pathname: "gallery" (wildcard) @@ -395,6 +433,7 @@ describe('trie', () => { '3', // protocol: "https" (skipped: 3) '3', // hostname: "com" (skipped: 3) '3', // hostname: "example" (skipped: 3) + '3', // port: "" (skipped: 3) '0', // pathname: "assets" (static: 0) '11110000', // pathname: "logo" (variable) + ".png" (static) ], @@ -415,6 +454,7 @@ describe('trie', () => { '3', // protocol: "https" (skipped: 3) '3', // hostname: "com" (skipped: 3) '3', // hostname: "example" (skipped: 3) + '3', // port: "" (skipped: 3) '0', // pathname: "api" (static: 0) '0100000', // pathname: "v" (static) + "2" (variable) + "-beta" (static) ], @@ -435,6 +475,7 @@ describe('trie', () => { '3', // protocol: "https" (skipped: 3) '3', // hostname: "com" (skipped: 3) '3', // hostname: "example" (skipped: 3) + '3', // port: "" (skipped: 3) '0', // pathname: "docs" (static: 0) '222222', // pathname: "guides" (wildcard) '22222', // pathname: "intro" (wildcard) @@ -457,6 +498,7 @@ describe('trie', () => { '3', // protocol: "https" (skipped: 3) '3', // hostname: "com" (skipped: 3) '3', // hostname: "example" (skipped: 3) + '3', // port: "" (skipped: 3) '0', // pathname: "org" (static: 0) '1111', // pathname: "acme" (variable) '22222222', // pathname: "projects" (wildcard) @@ -489,6 +531,7 @@ describe('trie', () => { '3', // protocol: "https" (skipped: 3) '3', // hostname: "com" (skipped: 3) '3', // hostname: "example" (skipped: 3) + '3', // port: "" (skipped: 3) '0', // pathname: "files" (static: 0) '0000000222222222', // pathname: "images-" (static) + "photo.jpg" (wildcard) ], @@ -501,6 +544,7 @@ describe('trie', () => { '3', // protocol: "https" (skipped: 3) '3', // hostname: "com" (skipped: 3) '3', // hostname: "example" (skipped: 3) + '3', // port: "" (skipped: 3) '0', // pathname: "files" (static: 0) '2222222222222222', // pathname: "images-photo.jpg" (all wildcard) ], @@ -521,6 +565,7 @@ describe('trie', () => { '3', // protocol: "https" (skipped: 3) '3', // hostname: "com" (skipped: 3) '3', // hostname: "example" (skipped: 3) + '3', // port: "" (skipped: 3) '0', // pathname: "api" (static: 0) '11', // pathname: "v2" (variable) '0', // pathname: "users" (static: 0) diff --git a/packages/route-pattern/src/lib2/matchers/trie/trie.ts b/packages/route-pattern/src/lib2/matchers/trie/trie.ts new file mode 100644 index 00000000000..432e2aeef81 --- /dev/null +++ b/packages/route-pattern/src/lib2/matchers/trie/trie.ts @@ -0,0 +1,289 @@ +import { RegExp_escape } from '../../es2025.ts' +import * as RoutePattern from '../../route-pattern/index.ts' +import type { Params } from '../matcher.ts' +import type * as Rank from './rank.ts' + +const SEPARATORS = ['', '.', '', '/'] +const NOT_SEPARATORS = [/.*/g, /[^.]/g, /.*/g, /[^/]/g] + +const RANK: Record = { + skip: '3', + wildcard: '2', + variable: '1', + static: '0', +} + +type Value = { + paramNames: Array + paramIndices: Set + data: data +} + +export type Match = { + rank: Array + params: Params + data: data +} + +export class Trie { + static: Record | undefined> = {} + variable: Map }> = new Map() + wildcard: Map }> = new Map() + next?: Trie + value?: Value + + insert(pattern: RoutePattern.AST, data: data) { + type State = { + partIndex: number + segmentIndex: number + trie: Trie + } + + for (let variant of RoutePattern.variants(pattern)) { + let value: Value = { + paramNames: RoutePattern.paramNames(pattern), + paramIndices: variant.paramIndices, + data, + } + + let state: State = { partIndex: 0, segmentIndex: 0, trie: this } + while (state.partIndex < 4) { + let part = variant.key[state.partIndex] + + if (state.segmentIndex === part.length) { + if (!state.trie.next) state.trie.next = new Trie() + state.partIndex += 1 + state.segmentIndex = 0 + state.trie = state.trie.next + continue + } + + let segment = part[state.segmentIndex] + let separator = SEPARATORS[state.partIndex] + + // wildcard + if (segment.includes('{*}')) { + let key = part.slice(state.segmentIndex).join(separator) + let next = state.trie.wildcard.get(key) + if (!next) { + next = { regexp: keyToRegExp(key, separator), trie: new Trie() } + state.trie.wildcard.set(key, next) + } + state.partIndex += 1 + state.segmentIndex = 0 + state.trie = next.trie + continue + } + + // variable + if (segment.includes('{:}')) { + let next = state.trie.variable.get(segment) + if (!next) { + next = { regexp: keyToRegExp(segment, separator), trie: new Trie() } + state.trie.variable.set(segment, next) + } + state.segmentIndex += 1 + state.trie = next.trie + continue + } + + // static + let next = state.trie.static[segment] + if (!next) { + next = new Trie() + state.trie.static[segment] = next + } + state.segmentIndex += 1 + state.trie = next + } + + state.trie.value = value + } + } + + *search(url: URL): Generator> { + let protocol = url.protocol.slice(0, -1) + let hostname = url.hostname.split('.').reverse() + let pathname = url.pathname.slice(1).split('/') + let query = [[protocol], hostname, [url.port], pathname] + + type State = { + partIndex: number + segmentIndex: number + trie: Trie + paramValues: Array + rank: Array + } + let stack: Array = [ + { + partIndex: 0, + segmentIndex: 0, + trie: this, + paramValues: [], + rank: [], + }, + ] + + while (stack.length > 0) { + let state = stack.pop()! + + if (state.partIndex === query.length) { + let { value } = state.trie + if (value) { + yield { + rank: state.rank, + params: params(value.paramNames, value.paramIndices, state.paramValues), + data: value.data, + } + } + continue + } + + let part = query[state.partIndex] + if (state.segmentIndex === part.length) { + if (state.trie.next) { + stack.push({ + partIndex: state.partIndex + 1, + segmentIndex: 0, + trie: state.trie.next, + paramValues: state.paramValues, + rank: state.rank, + }) + } + continue + } + + let segment = part[state.segmentIndex] + + let staticMatch = state.trie.static[segment] + if (staticMatch) { + let rank = state.rank.slice() + rank.push(RANK.static) + stack.push({ + partIndex: state.partIndex, + segmentIndex: state.segmentIndex + 1, + trie: staticMatch, + paramValues: state.paramValues, + rank, + }) + } + + let separator = SEPARATORS[state.partIndex] + let notSeparator = NOT_SEPARATORS[state.partIndex] + + for (let { regexp, trie } of state.trie.variable.values()) { + let match = regexp.exec(segment) + if (match) { + let dynamic = dynamicMatch(match, separator, notSeparator) + + let paramValues = state.paramValues.slice() + paramValues.push(...dynamic.paramValues) + + let rank = state.rank.slice() + rank.push(...dynamic.rank) + + stack.push({ + partIndex: state.partIndex, + segmentIndex: state.segmentIndex + 1, + trie, + paramValues, + rank, + }) + } + } + + for (let { regexp, trie } of state.trie.wildcard.values()) { + let rest = part.slice(state.segmentIndex).join(separator) + let match = regexp.exec(rest) + if (match) { + let dynamic = dynamicMatch(match, separator, notSeparator) + + let paramValues = state.paramValues.slice() + paramValues.push(...dynamic.paramValues) + + let rank = state.rank.slice() + rank.push(...dynamic.rank) + + stack.push({ + partIndex: state.partIndex + 1, + segmentIndex: 0, + trie, + paramValues, + rank, + }) + } + } + + // Consider skipping an entire part + // For example, a pattern like `://remix.run/about` + // will want to "skip" the protocol + // todo: better explanation + if (state.segmentIndex === 0 && state.trie.next) { + let rank = state.rank.slice() + query[state.partIndex].forEach(() => rank.push(RANK.skip)) + stack.push({ + partIndex: state.partIndex + 1, + segmentIndex: 0, + trie: state.trie.next, + paramValues: state.paramValues, + rank, + }) + } + } + } +} + +function params(paramNames: Array, paramIndices: Set, paramValues: Array) { + let result: Params = {} + + let valuesIndex = 0 + for (let i = 0; i < paramNames.length; i++) { + let name = paramNames[i] + if (paramIndices.has(i)) { + result[name] = paramValues[valuesIndex++] + continue + } + if (name in result) continue + result[name] = undefined + } + return result +} + +function keyToRegExp(key: string, separator: string): RegExp { + let variablePattern = `[^${RegExp_escape(separator)}]*` + let wildcardPattern = '.*' + + let i = 0 + let source = key + // use capture group so that `split` includes the delimiters in the result + .split(/(\{:\}|\{\*\})/) + .map((part) => { + if (part === '{*}') return `(?${wildcardPattern})` + if (part === '{:}') return `(?${variablePattern})` + return `(?${RegExp_escape(part)})` + }) + .join('') + + return new RegExp(`^${source}$`, 'd') +} + +function dynamicMatch( + match: RegExpExecArray, + separator: string, + notSeparator: RegExp, +): { paramValues: Array; rank: Rank.Type } { + let paramValues: Array = [] + let segmentRank = '' + Object.entries(match.indices?.groups ?? {}).forEach(([group, span]) => { + let type = group.split('_')[0] as keyof typeof RANK + let lexeme = match[0].slice(...span) + segmentRank += lexeme.replaceAll(notSeparator, RANK[type]) + if (type === 'variable' || type === 'wildcard') { + if (lexeme.length > 0) paramValues.push(lexeme) + } + }) + return { + paramValues, + rank: segmentRank.split(separator), + } +} diff --git a/packages/route-pattern/src/lib2/route-pattern/index.ts b/packages/route-pattern/src/lib2/route-pattern/index.ts index 3238526edaa..cdb289259d8 100644 --- a/packages/route-pattern/src/lib2/route-pattern/index.ts +++ b/packages/route-pattern/src/lib2/route-pattern/index.ts @@ -1,4 +1,5 @@ export type { AST } from './ast.ts' export { join } from './join.ts' +export { paramNames } from './param-names.ts' export { parse } from './parse.ts' export { variants } from './variants.ts' diff --git a/packages/route-pattern/src/lib2/route-pattern/param-names.ts b/packages/route-pattern/src/lib2/route-pattern/param-names.ts new file mode 100644 index 00000000000..e6af70c219d --- /dev/null +++ b/packages/route-pattern/src/lib2/route-pattern/param-names.ts @@ -0,0 +1,9 @@ +import type { AST } from './ast.ts' + +export function paramNames(ast: AST): Array { + let paramNames: Array = [] + if (ast.protocol) paramNames.push(...ast.protocol.paramNames) + if (ast.hostname) paramNames.push(...ast.hostname.paramNames) + if (ast.pathname) paramNames.push(...ast.pathname.paramNames) + return paramNames +} diff --git a/packages/route-pattern/src/lib2/route-pattern/part/variants.test.ts b/packages/route-pattern/src/lib2/route-pattern/part/variants.test.ts index dafc1140278..f7226ae9a8f 100644 --- a/packages/route-pattern/src/lib2/route-pattern/part/variants.test.ts +++ b/packages/route-pattern/src/lib2/route-pattern/part/variants.test.ts @@ -8,9 +8,9 @@ describe('variants', () => { let source = 'api/(v:major(.:minor)/)run' let ast = parse(source) expect(variants(ast)).toEqual([ - { key: 'api/run', paramNames: [] }, - { key: 'api/v{:}/run', paramNames: ['major'] }, - { key: 'api/v{:}.{:}/run', paramNames: ['major', 'minor'] }, + { key: 'api/run', paramIndices: [] }, + { key: 'api/v{:}/run', paramIndices: [0] }, + { key: 'api/v{:}.{:}/run', paramIndices: [0, 1] }, ]) }) }) diff --git a/packages/route-pattern/src/lib2/route-pattern/part/variants.ts b/packages/route-pattern/src/lib2/route-pattern/part/variants.ts index 7e16bf17758..b112835edce 100644 --- a/packages/route-pattern/src/lib2/route-pattern/part/variants.ts +++ b/packages/route-pattern/src/lib2/route-pattern/part/variants.ts @@ -2,14 +2,14 @@ import type { AST } from './ast' export type Variant = { key: string - paramNames: Array + paramIndices: Array } export function variants(ast: AST): Array { let result: Array = [] let q: Array<{ index: number; variant: Variant }> = [ - { index: 0, variant: { key: '', paramNames: [] } }, + { index: 0, variant: { key: '', paramIndices: [] } }, ] while (q.length > 0) { let { index, variant } = q.pop()! @@ -34,7 +34,7 @@ export function variants(ast: AST): Array { if (token.type === ':') { variant.key += '{:}' - variant.paramNames.push(ast.paramNames[token.nameIndex]) + variant.paramIndices.push(token.nameIndex) q.push({ index: index + 1, variant }) continue } @@ -42,7 +42,7 @@ export function variants(ast: AST): Array { if (token.type === '*') { variant.key += '{*}' if (token.nameIndex !== undefined) { - variant.paramNames.push(ast.paramNames[token.nameIndex]) + variant.paramIndices.push(token.nameIndex) } q.push({ index: index + 1, variant }) continue diff --git a/packages/route-pattern/src/lib2/route-pattern/variants.test.ts b/packages/route-pattern/src/lib2/route-pattern/variants.test.ts index b1f60309124..26a200c28a1 100644 --- a/packages/route-pattern/src/lib2/route-pattern/variants.test.ts +++ b/packages/route-pattern/src/lib2/route-pattern/variants.test.ts @@ -10,16 +10,16 @@ describe('variants', () => { let results = Array.from(variants(ast)) expect(results).toEqual([ { - key: [[], [], ['api', 'run']], - paramNames: [], + key: [[], [], [], ['api', 'run']], + paramIndices: new Set([]), }, { - key: [[], [], ['api', 'v{:}', 'run']], - paramNames: ['major'], + key: [[], [], [], ['api', 'v{:}', 'run']], + paramIndices: new Set([0]), }, { - key: [[], [], ['api', 'v{:}.{:}', 'run']], - paramNames: ['major', 'minor'], + key: [[], [], [], ['api', 'v{:}.{:}', 'run']], + paramIndices: new Set([0, 1]), }, ]) }) @@ -30,8 +30,8 @@ describe('variants', () => { let results = Array.from(variants(ast)) expect(results).toEqual([ { - key: [['https'], ['com', 'example'], ['users', '{:}']], - paramNames: ['id'], + key: [['https'], ['com', 'example'], [], ['users', '{:}']], + paramIndices: new Set([0]), }, ]) }) diff --git a/packages/route-pattern/src/lib2/route-pattern/variants.ts b/packages/route-pattern/src/lib2/route-pattern/variants.ts index 1b70b1fa4dc..528e4e8b27d 100644 --- a/packages/route-pattern/src/lib2/route-pattern/variants.ts +++ b/packages/route-pattern/src/lib2/route-pattern/variants.ts @@ -1,30 +1,45 @@ import type { AST } from './ast.ts' import * as Part from './part/index.ts' +type Tuple4 = [protocol: T, hostname: T, port: T, pathname: T] + type Variant = { - key: Array> - paramNames: Array + key: Tuple4> + paramIndices: Set } export function* variants(pattern: AST): Generator { - let protocols = pattern.protocol ? Part.variants(pattern.protocol) : undefined - let hostnames = pattern.hostname ? Part.variants(pattern.hostname) : undefined - let pathnames = pattern.pathname ? Part.variants(pattern.pathname) : undefined + let protocols = pattern.protocol ? Part.variants(pattern.protocol) : [undefined] + let hostnames = pattern.hostname ? Part.variants(pattern.hostname) : [undefined] + let pathnames = pattern.pathname ? Part.variants(pattern.pathname) : [undefined] + + for (let protocol of protocols) { + for (let hostname of hostnames) { + for (let pathname of pathnames) { + let paramIndices: Array = [] + + if (protocol) { + protocol.paramIndices.forEach((index) => paramIndices.push(index)) + } + + if (hostname) { + let offset = paramIndices.length + hostname.paramIndices.forEach((index) => paramIndices.push(offset + index)) + } + + if (pathname) { + let offset = paramIndices.length + pathname.paramIndices.forEach((index) => paramIndices.push(offset + index)) + } - for (let protocol of protocols ?? [null]) { - for (let hostname of hostnames ?? [null]) { - for (let pathname of pathnames ?? [null]) { yield { key: [ protocol ? [protocol.key] : [], hostname?.key.split('.').reverse() ?? [], + pattern.port ? [pattern.port] : [], pathname?.key.split('/') ?? [], ], - paramNames: [ - ...(protocol?.paramNames ?? []), - ...(hostname?.paramNames ?? []), - ...(pathname?.paramNames ?? []), - ], + paramIndices: new Set(paramIndices), } } } From 2de6d4e724c41a86b9e2298e958f745c41d5aca0 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 17 Dec 2025 14:36:38 -0500 Subject: [PATCH 43/54] fix Rank.comparison when rank only differs beyond length of other rank --- packages/route-pattern/src/lib2/matchers/trie/rank.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/route-pattern/src/lib2/matchers/trie/rank.ts b/packages/route-pattern/src/lib2/matchers/trie/rank.ts index 062bdffa50b..2bd4a7f131b 100644 --- a/packages/route-pattern/src/lib2/matchers/trie/rank.ts +++ b/packages/route-pattern/src/lib2/matchers/trie/rank.ts @@ -12,5 +12,7 @@ export function compare(a: Rank, b: Rank): -1 | 0 | 1 { if (segmentA < segmentB) return -1 if (segmentA > segmentB) return 1 } + if (a.length < b.length) return -1 + if (a.length > b.length) return 1 return 0 } From f971fc86c4dee7898a9e4976a3d4590d019cc753 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 17 Dec 2025 15:17:27 -0500 Subject: [PATCH 44/54] replace Object.entries(...) with for...in no need to create intermediate array --- packages/route-pattern/src/lib2/matchers/trie/trie.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/route-pattern/src/lib2/matchers/trie/trie.ts b/packages/route-pattern/src/lib2/matchers/trie/trie.ts index 432e2aeef81..cbf6a71bdca 100644 --- a/packages/route-pattern/src/lib2/matchers/trie/trie.ts +++ b/packages/route-pattern/src/lib2/matchers/trie/trie.ts @@ -274,14 +274,16 @@ function dynamicMatch( ): { paramValues: Array; rank: Rank.Type } { let paramValues: Array = [] let segmentRank = '' - Object.entries(match.indices?.groups ?? {}).forEach(([group, span]) => { + for (let group in match.indices?.groups) { + let span = match.indices.groups[group] let type = group.split('_')[0] as keyof typeof RANK let lexeme = match[0].slice(...span) segmentRank += lexeme.replaceAll(notSeparator, RANK[type]) if (type === 'variable' || type === 'wildcard') { if (lexeme.length > 0) paramValues.push(lexeme) } - }) + } + return { paramValues, rank: segmentRank.split(separator), From 7305bf51e47d02f9c0d6414e7591edb07fffbfa9 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 17 Dec 2025 17:33:33 -0500 Subject: [PATCH 45/54] optimize simple dynamic segments --- .../src/lib2/matchers/trie/trie.ts | 45 +++++++++++++------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/packages/route-pattern/src/lib2/matchers/trie/trie.ts b/packages/route-pattern/src/lib2/matchers/trie/trie.ts index cbf6a71bdca..abb95cde5da 100644 --- a/packages/route-pattern/src/lib2/matchers/trie/trie.ts +++ b/packages/route-pattern/src/lib2/matchers/trie/trie.ts @@ -171,17 +171,26 @@ export class Trie { let separator = SEPARATORS[state.partIndex] let notSeparator = NOT_SEPARATORS[state.partIndex] - for (let { regexp, trie } of state.trie.variable.values()) { + for (let [key, { regexp, trie }] of state.trie.variable) { let match = regexp.exec(segment) if (match) { - let dynamic = dynamicMatch(match, separator, notSeparator) - let paramValues = state.paramValues.slice() - paramValues.push(...dynamic.paramValues) - let rank = state.rank.slice() + if (key === '{:}') { + paramValues.push(segment) + rank.push(RANK.variable.repeat(segment.length)) + stack.push({ + partIndex: state.partIndex, + segmentIndex: state.segmentIndex + 1, + trie, + paramValues, + rank, + }) + continue + } + let dynamic = dynamicMatch(match, separator, notSeparator) + paramValues.push(...dynamic.paramValues) rank.push(...dynamic.rank) - stack.push({ partIndex: state.partIndex, segmentIndex: state.segmentIndex + 1, @@ -192,18 +201,28 @@ export class Trie { } } - for (let { regexp, trie } of state.trie.wildcard.values()) { - let rest = part.slice(state.segmentIndex).join(separator) + for (let [key, { regexp, trie }] of state.trie.wildcard) { + let segments = part.slice(state.segmentIndex) + let rest = segments.join(separator) let match = regexp.exec(rest) if (match) { - let dynamic = dynamicMatch(match, separator, notSeparator) - let paramValues = state.paramValues.slice() - paramValues.push(...dynamic.paramValues) - let rank = state.rank.slice() + if (key === '{*}') { + paramValues.push(rest) + segments.forEach((segment) => rank.push(RANK.wildcard.repeat(segment.length))) + stack.push({ + partIndex: state.partIndex + 1, + segmentIndex: 0, + trie, + paramValues, + rank, + }) + continue + } + let dynamic = dynamicMatch(match, separator, notSeparator) + paramValues.push(...dynamic.paramValues) rank.push(...dynamic.rank) - stack.push({ partIndex: state.partIndex + 1, segmentIndex: 0, From 58089f0e496ce902bee4b3f8be1f418041c943c2 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 17 Dec 2025 20:52:25 -0500 Subject: [PATCH 46/54] RoutePattern search --- .../src/lib2/route-pattern/ast.ts | 3 +- .../src/lib2/route-pattern/join.test.ts | 7 +- .../src/lib2/route-pattern/join.ts | 3 +- .../src/lib2/route-pattern/parse.ts | 3 +- .../src/lib2/route-pattern/search.test.ts | 199 ++++++++++++++++++ .../src/lib2/route-pattern/search.ts | 78 +++++++ 6 files changed, 289 insertions(+), 4 deletions(-) create mode 100644 packages/route-pattern/src/lib2/route-pattern/search.test.ts create mode 100644 packages/route-pattern/src/lib2/route-pattern/search.ts diff --git a/packages/route-pattern/src/lib2/route-pattern/ast.ts b/packages/route-pattern/src/lib2/route-pattern/ast.ts index 5243ff3136a..287c9cdd1a2 100644 --- a/packages/route-pattern/src/lib2/route-pattern/ast.ts +++ b/packages/route-pattern/src/lib2/route-pattern/ast.ts @@ -1,9 +1,10 @@ import type * as Part from './part/index.ts' +import type * as Search from './search.ts' export type AST = { protocol: Part.AST | undefined hostname: Part.AST | undefined port: string | undefined pathname: Part.AST | undefined - search: string | undefined // todo + search: Search.Constraints | undefined } diff --git a/packages/route-pattern/src/lib2/route-pattern/join.test.ts b/packages/route-pattern/src/lib2/route-pattern/join.test.ts index 1e3dcff6a77..cc309b5b9a6 100644 --- a/packages/route-pattern/src/lib2/route-pattern/join.test.ts +++ b/packages/route-pattern/src/lib2/route-pattern/join.test.ts @@ -235,7 +235,12 @@ describe('join', () => { let a = parse('/path?a=1') let b = parse('/other?b=2') let result = join(a, b) - expect(result.search).toBe('b=2') + expect(result.search).toStrictEqual( + new Map([ + ['a', new Set(['1'])], + ['b', new Set(['2'])], + ]), + ) }) }) diff --git a/packages/route-pattern/src/lib2/route-pattern/join.ts b/packages/route-pattern/src/lib2/route-pattern/join.ts index 8b61f2b8c3e..c503308ea79 100644 --- a/packages/route-pattern/src/lib2/route-pattern/join.ts +++ b/packages/route-pattern/src/lib2/route-pattern/join.ts @@ -1,5 +1,6 @@ import type { AST } from './ast.ts' import type * as Part from './part/index.ts' +import * as Search from './search.ts' export function join(a: AST, b: AST): AST { return { @@ -7,7 +8,7 @@ export function join(a: AST, b: AST): AST { hostname: b.hostname ?? a.hostname, port: b.port ?? a.port, pathname: joinPathname(a.pathname, b.pathname), - search: b.search, // todo + search: Search.join(a.search, b.search), } } diff --git a/packages/route-pattern/src/lib2/route-pattern/parse.ts b/packages/route-pattern/src/lib2/route-pattern/parse.ts index 41401e0a860..7c7a9c8db8e 100644 --- a/packages/route-pattern/src/lib2/route-pattern/parse.ts +++ b/packages/route-pattern/src/lib2/route-pattern/parse.ts @@ -1,6 +1,7 @@ import type { AST } from './ast.ts' import { split } from './split.ts' import * as Part from './part/index.ts' +import * as Search from './search.ts' export function parse(source: string): AST { let ast: AST = { @@ -26,7 +27,7 @@ export function parse(source: string): AST { ast.pathname = Part.parse(source, pathname) } if (search && search[0] !== search[1]) { - ast.search = source.slice(...search) + ast.search = Search.parse(source.slice(...search)) } return ast } diff --git a/packages/route-pattern/src/lib2/route-pattern/search.test.ts b/packages/route-pattern/src/lib2/route-pattern/search.test.ts new file mode 100644 index 00000000000..b2fd507e7a2 --- /dev/null +++ b/packages/route-pattern/src/lib2/route-pattern/search.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest' + +import * as Search from './search.ts' + +describe('parse', () => { + it('parses presence-only constraint', () => { + expect(Search.parse('q')).toEqual(new Map([['q', null]])) + }) + + it('parses empty value constraint', () => { + expect(Search.parse('q=')).toEqual(new Map([['q', new Set()]])) + }) + + it('parses single value constraint', () => { + expect(Search.parse('q=hello')).toEqual(new Map([['q', new Set(['hello'])]])) + }) + + it('parses multiple values for same param', () => { + expect(Search.parse('tag=a&tag=b&tag=c')).toEqual(new Map([['tag', new Set(['a', 'b', 'c'])]])) + }) + + it('parses multiple different params', () => { + expect(Search.parse('a=1&b=2')).toEqual( + new Map([ + ['a', new Set(['1'])], + ['b', new Set(['2'])], + ]), + ) + }) + + it('parses mixed constraint types', () => { + expect(Search.parse('present&empty=&valued=x')).toEqual( + new Map([ + ['present', null], + ['empty', new Set()], + ['valued', new Set(['x'])], + ]), + ) + }) + + it('decodes URL-encoded param names and values', () => { + expect(Search.parse('hello%20world=foo%26bar')).toEqual( + new Map([['hello world', new Set(['foo&bar'])]]), + ) + }) + + it('presence constraint is not overwritten by later value', () => { + expect(Search.parse('q&q=1')).toEqual(new Map([['q', new Set(['1'])]])) + }) + + it('skips empty params from consecutive ampersands', () => { + expect(Search.parse('a=1&&b=2')).toEqual( + new Map([ + ['a', new Set(['1'])], + ['b', new Set(['2'])], + ]), + ) + }) +}) + +describe('match', () => { + it('matches presence-only constraint when param exists', () => { + let constraints: Search.Constraints = new Map([['q', null]]) + expect(Search.match(new URLSearchParams('q'), constraints)).toBe(true) + expect(Search.match(new URLSearchParams('q='), constraints)).toBe(true) + expect(Search.match(new URLSearchParams('q=hello'), constraints)).toBe(true) + }) + + it('fails presence-only constraint when param is missing', () => { + let constraints: Search.Constraints = new Map([['q', null]]) + expect(Search.match(new URLSearchParams(''), constraints)).toBe(false) + expect(Search.match(new URLSearchParams('other=value'), constraints)).toBe(false) + }) + + it('matches empty value constraint when param has non-empty value', () => { + let constraints: Search.Constraints = new Map([['q', new Set()]]) + expect(Search.match(new URLSearchParams('q=hello'), constraints)).toBe(true) + expect(Search.match(new URLSearchParams('q=a&q=b'), constraints)).toBe(true) + }) + + it('fails empty value constraint when all values are empty', () => { + let constraints: Search.Constraints = new Map([['q', new Set()]]) + expect(Search.match(new URLSearchParams('q='), constraints)).toBe(false) + expect(Search.match(new URLSearchParams('q=&q='), constraints)).toBe(false) + }) + + it('matches specific value constraint when value is present', () => { + let constraints: Search.Constraints = new Map([['q', new Set(['hello'])]]) + expect(Search.match(new URLSearchParams('q=hello'), constraints)).toBe(true) + expect(Search.match(new URLSearchParams('q=hello&q=world'), constraints)).toBe(true) + }) + + it('fails specific value constraint when value is missing', () => { + let constraints: Search.Constraints = new Map([['q', new Set(['hello'])]]) + expect(Search.match(new URLSearchParams('q=world'), constraints)).toBe(false) + expect(Search.match(new URLSearchParams('q='), constraints)).toBe(false) + expect(Search.match(new URLSearchParams(''), constraints)).toBe(false) + }) + + it('matches multiple value constraints when all values are present', () => { + let constraints: Search.Constraints = new Map([['tag', new Set(['a', 'b'])]]) + expect(Search.match(new URLSearchParams('tag=a&tag=b'), constraints)).toBe(true) + expect(Search.match(new URLSearchParams('tag=b&tag=a&tag=c'), constraints)).toBe(true) + }) + + it('fails multiple value constraints when some values are missing', () => { + let constraints: Search.Constraints = new Map([['tag', new Set(['a', 'b'])]]) + expect(Search.match(new URLSearchParams('tag=a'), constraints)).toBe(false) + expect(Search.match(new URLSearchParams('tag=b'), constraints)).toBe(false) + expect(Search.match(new URLSearchParams('tag=c'), constraints)).toBe(false) + }) + + it('matches multiple different param constraints', () => { + let constraints: Search.Constraints = new Map([ + ['a', new Set(['1'])], + ['b', null], + ]) + expect(Search.match(new URLSearchParams('a=1&b'), constraints)).toBe(true) + expect(Search.match(new URLSearchParams('a=1&b=2'), constraints)).toBe(true) + }) + + it('fails when any constraint is not satisfied', () => { + let constraints: Search.Constraints = new Map([ + ['a', new Set(['1'])], + ['b', null], + ]) + expect(Search.match(new URLSearchParams('a=1'), constraints)).toBe(false) + expect(Search.match(new URLSearchParams('b=2'), constraints)).toBe(false) + }) + + it('matches with no constraints', () => { + let constraints: Search.Constraints = new Map() + expect(Search.match(new URLSearchParams(''), constraints)).toBe(true) + expect(Search.match(new URLSearchParams('any=value'), constraints)).toBe(true) + }) +}) + +describe('join', () => { + it('joins two empty constraints', () => { + let a: Search.Constraints = new Map() + let b: Search.Constraints = new Map() + expect(Search.join(a, b)).toEqual(new Map()) + }) + + it('joins with empty constraint on left', () => { + let a: Search.Constraints = new Map() + let b: Search.Constraints = new Map([['q', new Set(['1'])]]) + expect(Search.join(a, b)).toEqual(new Map([['q', new Set(['1'])]])) + }) + + it('joins with empty constraint on right', () => { + let a: Search.Constraints = new Map([['q', new Set(['1'])]]) + let b: Search.Constraints = new Map() + expect(Search.join(a, b)).toEqual(new Map([['q', new Set(['1'])]])) + }) + + it('joins disjoint constraints', () => { + let a: Search.Constraints = new Map([['a', new Set(['1'])]]) + let b: Search.Constraints = new Map([['b', new Set(['2'])]]) + expect(Search.join(a, b)).toEqual( + new Map([ + ['a', new Set(['1'])], + ['b', new Set(['2'])], + ]), + ) + }) + + it('merges values for same param', () => { + let a: Search.Constraints = new Map([['q', new Set(['1'])]]) + let b: Search.Constraints = new Map([['q', new Set(['2'])]]) + expect(Search.join(a, b)).toEqual(new Map([['q', new Set(['1', '2'])]])) + }) + + it('presence constraint in a is overwritten by values in b', () => { + let a: Search.Constraints = new Map([['q', null]]) + let b: Search.Constraints = new Map([['q', new Set(['1'])]]) + expect(Search.join(a, b)).toEqual(new Map([['q', new Set(['1'])]])) + }) + + it('values in a are preserved when b has presence constraint', () => { + let a: Search.Constraints = new Map([['q', new Set(['1'])]]) + let b: Search.Constraints = new Map([['q', null]]) + expect(Search.join(a, b)).toEqual(new Map([['q', new Set(['1'])]])) + }) + + it('presence constraint in a is overwritten by presence in b', () => { + let a: Search.Constraints = new Map([['q', null]]) + let b: Search.Constraints = new Map([['q', null]]) + expect(Search.join(a, b)).toEqual(new Map([['q', null]])) + }) + + it('does not mutate original constraints', () => { + let a: Search.Constraints = new Map([['q', new Set(['1'])]]) + let b: Search.Constraints = new Map([['q', new Set(['2'])]]) + Search.join(a, b) + expect(a).toEqual(new Map([['q', new Set(['1'])]])) + expect(b).toEqual(new Map([['q', new Set(['2'])]])) + }) +}) diff --git a/packages/route-pattern/src/lib2/route-pattern/search.ts b/packages/route-pattern/src/lib2/route-pattern/search.ts new file mode 100644 index 00000000000..12f5ee65c0b --- /dev/null +++ b/packages/route-pattern/src/lib2/route-pattern/search.ts @@ -0,0 +1,78 @@ +export type Constraints = Map | null> + +export function parse(search: string): Constraints { + let constraints: Constraints = new Map() + + for (let param of search.split('&')) { + if (param === '') continue + let equalIndex = param.indexOf('=') + + // `?q` + if (equalIndex === -1) { + let name = decodeURIComponent(param) + if (!constraints.get(name)) { + constraints.set(name, null) + } + continue + } + + let name = decodeURIComponent(param.slice(0, equalIndex)) + let value = decodeURIComponent(param.slice(equalIndex + 1)) + + // `?q=` + if (value.length === 0) { + if (!constraints.get(name)) { + constraints.set(name, new Set()) + } + continue + } + + // `?q=1` + let constraint = constraints.get(name) + constraints.set(name, constraint ? constraint.add(value) : new Set([value])) + } + + return constraints +} + +// todo: URLSearchParams.get() will treat `+` as spaces!!! +// should we account for that? probably yes so that we can compare apples-to-apples in `match` + +export function match(params: URLSearchParams, constraints: Constraints): boolean { + for (let [name, constraint] of constraints) { + if (constraint === null) { + if (!params.has(name)) return false + continue + } + + let values = params.getAll(name) + + if (constraint.size === 0) { + if (values.every((value) => value === '')) return false + continue + } + + for (let value of constraint) { + if (!values.includes(value)) return false + } + } + return true +} + +export function join(a: Constraints | undefined, b: Constraints | undefined): Constraints { + let result: Constraints = new Map() + for (let [name, constraint] of a ?? []) { + result.set(name, constraint === null ? null : new Set(constraint)) + } + for (let [name, constraint] of b ?? []) { + let current = result.get(name) + + if (current === null || current === undefined) { + result.set(name, constraint === null ? null : new Set(constraint)) + continue + } + + constraint?.forEach((value) => current.add(value)) + } + return result +} From 8e9d2ab8ac593f6371f93f353702548dac0f3b08 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 18 Dec 2025 10:48:51 -0500 Subject: [PATCH 47/54] search ranking --- .../src/lib2/matchers/trie/rank.ts | 16 +- .../src/lib2/matchers/trie/trie.test.ts | 316 ++++++++++++------ .../src/lib2/matchers/trie/trie.ts | 33 +- .../src/lib2/route-pattern/ast.ts | 2 +- .../src/lib2/route-pattern/index.ts | 2 + .../src/lib2/route-pattern/parse.test.ts | 6 +- .../src/lib2/route-pattern/parse.ts | 3 +- .../src/lib2/route-pattern/search.ts | 51 ++- 8 files changed, 307 insertions(+), 122 deletions(-) diff --git a/packages/route-pattern/src/lib2/matchers/trie/rank.ts b/packages/route-pattern/src/lib2/matchers/trie/rank.ts index 2bd4a7f131b..35ad5619d87 100644 --- a/packages/route-pattern/src/lib2/matchers/trie/rank.ts +++ b/packages/route-pattern/src/lib2/matchers/trie/rank.ts @@ -1,11 +1,21 @@ -type Rank = Array -export type Type = Rank +import * as RoutePattern from '../../route-pattern/index.ts' + +type Rank = { + hierarchical: Array + search: RoutePattern.Search.Constraints +} export function lessThan(a: Rank, b: Rank): boolean { return compare(a, b) === -1 } -export function compare(a: Rank, b: Rank): -1 | 0 | 1 { +export function compare(a: Rank, b: Rank): number { + let hierarchical = compareHierarchical(a.hierarchical, b.hierarchical) + if (hierarchical !== 0) return hierarchical + return RoutePattern.Search.compare(a.search, b.search) +} + +function compareHierarchical(a: Rank['hierarchical'], b: Rank['hierarchical']): -1 | 0 | 1 { for (let i = 0; i < a.length; i++) { let segmentA = a[i] let segmentB = b[i] diff --git a/packages/route-pattern/src/lib2/matchers/trie/trie.test.ts b/packages/route-pattern/src/lib2/matchers/trie/trie.test.ts index d8376f893ed..54ef27442b8 100644 --- a/packages/route-pattern/src/lib2/matchers/trie/trie.test.ts +++ b/packages/route-pattern/src/lib2/matchers/trie/trie.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { parse } from '../../route-pattern/parse.ts' import type * as RoutePattern from '../../route-pattern/index.ts' +import * as Search from '../../route-pattern/search.ts' import { Trie } from './trie.ts' function searchAll(trie: Trie, url: URL) { @@ -16,7 +17,7 @@ describe('Trie', () => { expect(trie.variable).toEqual(new Map()) expect(trie.wildcard).toEqual(new Map()) expect(trie.next).toBe(undefined) - expect(trie.value).toBe(undefined) + expect(trie.values).toEqual([]) }) }) @@ -198,15 +199,15 @@ describe('Trie', () => { let data = { route: 'user-detail' } trie.insert(pattern, data) - // Navigate to the leaf node: protocol -> hostname -> port -> pathname (users) -> variable ({:}) -> port -> search + // Navigate to the leaf node: protocol -> hostname -> port -> pathname (users) -> variable ({:}) -> next (end) let pathnameLevel = trie.next?.next?.next let usersLevel = pathnameLevel?.static['users'] let variableLevel = usersLevel?.variable.get('{:}')?.trie // After variable, we need to traverse to the end let leaf = variableLevel?.next - expect(leaf?.value).toBeTruthy() - expect(leaf?.value?.data).toBe(data) - expect(leaf?.value?.paramNames).toEqual(['id']) + expect(leaf?.values[0]).toBeTruthy() + expect(leaf?.values[0]?.data).toBe(data) + expect(leaf?.values[0]?.paramNames).toEqual(['id']) }) it('stores correct paramNames for multiple params', () => { @@ -223,8 +224,8 @@ describe('Trie', () => { let repoIdLevel = repoLevel?.variable.get('{:}')?.trie let leaf = repoIdLevel?.next - expect(leaf?.value).toBeTruthy() - expect(leaf?.value?.paramNames).toEqual(['orgId', 'repoId']) + expect(leaf?.values[0]).toBeTruthy() + expect(leaf?.values[0]?.paramNames).toEqual(['orgId', 'repoId']) }) }) @@ -372,27 +373,33 @@ describe('Trie', () => { let dynamicMatch = matches.find((m) => m.data === dynamicPattern) expect(staticMatch).toStrictEqual({ - rank: [ - '3', // protocol: "https" (skipped: 3) - '3', // hostname: "com" (skipped: 3) - '3', // hostname: "example" (skipped: 3) - '3', // port: "" (skipped: 3) - '0', // pathname: "users" (static: 0) - '0', // pathname: "@admin" (static: 0) - ], + rank: { + hierarchical: [ + '3', // protocol: "https" (skipped: 3) + '3', // hostname: "com" (skipped: 3) + '3', // hostname: "example" (skipped: 3) + '3', // port: "" (skipped: 3) + '0', // pathname: "users" (static: 0) + '0', // pathname: "@admin" (static: 0) + ], + search: new Map(), + }, params: {}, data: staticPattern, }) expect(dynamicMatch).toStrictEqual({ - rank: [ - '3', // protocol: "https" (skipped: 3) - '3', // hostname: "com" (skipped: 3) - '3', // hostname: "example" (skipped: 3) - '3', // port: "" (skipped: 3) - '0', // pathname: "users" (static: 0) - '011111', // pathname: "@admin" (dynamic: "011111") - ], + rank: { + hierarchical: [ + '3', // protocol: "https" (skipped: 3) + '3', // hostname: "com" (skipped: 3) + '3', // hostname: "example" (skipped: 3) + '3', // port: "" (skipped: 3) + '0', // pathname: "users" (static: 0) + '011111', // pathname: "@admin" (dynamic: "011111") + ], + search: new Map(), + }, params: { id: 'admin' }, data: dynamicPattern, }) @@ -406,16 +413,19 @@ describe('Trie', () => { let matches = searchAll(trie, new URL('https://example.com/files/image-photo/gallery/2024')) expect(matches.length).toBe(1) expect(matches[0]).toStrictEqual({ - rank: [ - '3', // protocol: "https" (skipped: 3) - '3', // hostname: "com" (skipped: 3) - '3', // hostname: "example" (skipped: 3) - '3', // port: "" (skipped: 3) - '0', // pathname: "files" (static: 0) - '00000022222', // pathname: "image-" (static) + "photo" (wildcard) - '2222222', // pathname: "gallery" (wildcard) - '2222', // pathname: "2024" (wildcard) - ], + rank: { + hierarchical: [ + '3', // protocol: "https" (skipped: 3) + '3', // hostname: "com" (skipped: 3) + '3', // hostname: "example" (skipped: 3) + '3', // port: "" (skipped: 3) + '0', // pathname: "files" (static: 0) + '00000022222', // pathname: "image-" (static) + "photo" (wildcard) + '2222222', // pathname: "gallery" (wildcard) + '2222', // pathname: "2024" (wildcard) + ], + search: new Map(), + }, params: { rest: 'photo/gallery/2024' }, data: pattern, }) @@ -429,14 +439,17 @@ describe('Trie', () => { let matches = searchAll(trie, new URL('https://example.com/assets/logo.png')) expect(matches.length).toBe(1) expect(matches[0]).toStrictEqual({ - rank: [ - '3', // protocol: "https" (skipped: 3) - '3', // hostname: "com" (skipped: 3) - '3', // hostname: "example" (skipped: 3) - '3', // port: "" (skipped: 3) - '0', // pathname: "assets" (static: 0) - '11110000', // pathname: "logo" (variable) + ".png" (static) - ], + rank: { + hierarchical: [ + '3', // protocol: "https" (skipped: 3) + '3', // hostname: "com" (skipped: 3) + '3', // hostname: "example" (skipped: 3) + '3', // port: "" (skipped: 3) + '0', // pathname: "assets" (static: 0) + '11110000', // pathname: "logo" (variable) + ".png" (static) + ], + search: new Map(), + }, params: { name: 'logo' }, data: pattern, }) @@ -450,14 +463,17 @@ describe('Trie', () => { let matches = searchAll(trie, new URL('https://example.com/api/v2-beta')) expect(matches.length).toBe(1) expect(matches[0]).toStrictEqual({ - rank: [ - '3', // protocol: "https" (skipped: 3) - '3', // hostname: "com" (skipped: 3) - '3', // hostname: "example" (skipped: 3) - '3', // port: "" (skipped: 3) - '0', // pathname: "api" (static: 0) - '0100000', // pathname: "v" (static) + "2" (variable) + "-beta" (static) - ], + rank: { + hierarchical: [ + '3', // protocol: "https" (skipped: 3) + '3', // hostname: "com" (skipped: 3) + '3', // hostname: "example" (skipped: 3) + '3', // port: "" (skipped: 3) + '0', // pathname: "api" (static: 0) + '0100000', // pathname: "v" (static) + "2" (variable) + "-beta" (static) + ], + search: new Map(), + }, params: { version: '2' }, data: pattern, }) @@ -471,16 +487,19 @@ describe('Trie', () => { let matches = searchAll(trie, new URL('https://example.com/docs/guides/intro/edit')) expect(matches.length).toBe(1) expect(matches[0]).toStrictEqual({ - rank: [ - '3', // protocol: "https" (skipped: 3) - '3', // hostname: "com" (skipped: 3) - '3', // hostname: "example" (skipped: 3) - '3', // port: "" (skipped: 3) - '0', // pathname: "docs" (static: 0) - '222222', // pathname: "guides" (wildcard) - '22222', // pathname: "intro" (wildcard) - '0000', // pathname: "edit" (static) - ], + rank: { + hierarchical: [ + '3', // protocol: "https" (skipped: 3) + '3', // hostname: "com" (skipped: 3) + '3', // hostname: "example" (skipped: 3) + '3', // port: "" (skipped: 3) + '0', // pathname: "docs" (static: 0) + '222222', // pathname: "guides" (wildcard) + '22222', // pathname: "intro" (wildcard) + '0000', // pathname: "edit" (static) + ], + search: new Map(), + }, params: { path: 'guides/intro' }, data: pattern, }) @@ -494,17 +513,20 @@ describe('Trie', () => { let matches = searchAll(trie, new URL('https://example.com/org/acme/projects/web/settings')) expect(matches.length).toBe(1) expect(matches[0]).toStrictEqual({ - rank: [ - '3', // protocol: "https" (skipped: 3) - '3', // hostname: "com" (skipped: 3) - '3', // hostname: "example" (skipped: 3) - '3', // port: "" (skipped: 3) - '0', // pathname: "org" (static: 0) - '1111', // pathname: "acme" (variable) - '22222222', // pathname: "projects" (wildcard) - '222', // pathname: "web" (wildcard) - '00000000', // pathname: "settings" (static) - ], + rank: { + hierarchical: [ + '3', // protocol: "https" (skipped: 3) + '3', // hostname: "com" (skipped: 3) + '3', // hostname: "example" (skipped: 3) + '3', // port: "" (skipped: 3) + '0', // pathname: "org" (static: 0) + '1111', // pathname: "acme" (variable) + '22222222', // pathname: "projects" (wildcard) + '222', // pathname: "web" (wildcard) + '00000000', // pathname: "settings" (static) + ], + search: new Map(), + }, params: { orgId: 'acme', path: 'projects/web' }, data: pattern, }) @@ -524,30 +546,38 @@ describe('Trie', () => { let lessStaticMatch = matches.find((m) => m.data === lessStatic) // More static should have lower rank (better) - string comparison works lexicographically - expect(moreStaticMatch!.rank.join(',') < lessStaticMatch!.rank.join(',')).toBe(true) + expect( + moreStaticMatch!.rank.hierarchical.join(',') < lessStaticMatch!.rank.hierarchical.join(','), + ).toBe(true) expect(moreStaticMatch).toStrictEqual({ - rank: [ - '3', // protocol: "https" (skipped: 3) - '3', // hostname: "com" (skipped: 3) - '3', // hostname: "example" (skipped: 3) - '3', // port: "" (skipped: 3) - '0', // pathname: "files" (static: 0) - '0000000222222222', // pathname: "images-" (static) + "photo.jpg" (wildcard) - ], + rank: { + hierarchical: [ + '3', // protocol: "https" (skipped: 3) + '3', // hostname: "com" (skipped: 3) + '3', // hostname: "example" (skipped: 3) + '3', // port: "" (skipped: 3) + '0', // pathname: "files" (static: 0) + '0000000222222222', // pathname: "images-" (static) + "photo.jpg" (wildcard) + ], + search: new Map(), + }, params: { rest: 'photo.jpg' }, data: moreStatic, }) expect(lessStaticMatch).toStrictEqual({ - rank: [ - '3', // protocol: "https" (skipped: 3) - '3', // hostname: "com" (skipped: 3) - '3', // hostname: "example" (skipped: 3) - '3', // port: "" (skipped: 3) - '0', // pathname: "files" (static: 0) - '2222222222222222', // pathname: "images-photo.jpg" (all wildcard) - ], + rank: { + hierarchical: [ + '3', // protocol: "https" (skipped: 3) + '3', // hostname: "com" (skipped: 3) + '3', // hostname: "example" (skipped: 3) + '3', // port: "" (skipped: 3) + '0', // pathname: "files" (static: 0) + '2222222222222222', // pathname: "images-photo.jpg" (all wildcard) + ], + search: new Map(), + }, params: { rest: 'images-photo.jpg' }, data: lessStatic, }) @@ -561,21 +591,109 @@ describe('Trie', () => { let matches = searchAll(trie, new URL('https://example.com/api/v2/users/123/posts/create')) expect(matches.length).toBe(1) expect(matches[0]).toStrictEqual({ - rank: [ - '3', // protocol: "https" (skipped: 3) - '3', // hostname: "com" (skipped: 3) - '3', // hostname: "example" (skipped: 3) - '3', // port: "" (skipped: 3) - '0', // pathname: "api" (static: 0) - '11', // pathname: "v2" (variable) - '0', // pathname: "users" (static: 0) - '111', // pathname: "123" (variable) - '22222', // pathname: "posts" (wildcard) - '222222', // pathname: "create" (wildcard) - ], + rank: { + hierarchical: [ + '3', // protocol: "https" (skipped: 3) + '3', // hostname: "com" (skipped: 3) + '3', // hostname: "example" (skipped: 3) + '3', // port: "" (skipped: 3) + '0', // pathname: "api" (static: 0) + '11', // pathname: "v2" (variable) + '0', // pathname: "users" (static: 0) + '111', // pathname: "123" (variable) + '22222', // pathname: "posts" (wildcard) + '222222', // pathname: "create" (wildcard) + ], + search: new Map(), + }, params: { version: 'v2', userId: '123', action: 'posts/create' }, data: pattern, }) }) + + it('ranks search constraints with exact values higher than existence checks', () => { + let trie = new Trie() + let exactValue = parse('users?role=admin') + let existsOnly = parse('users?role') + trie.insert(exactValue, exactValue) + trie.insert(existsOnly, existsOnly) + + let matches = searchAll(trie, new URL('https://example.com/users?role=admin')) + expect(matches.length).toBe(2) + + let exactMatch = matches.find((m) => m.data === exactValue)! + let existsMatch = matches.find((m) => m.data === existsOnly)! + + // ?role=admin is more specific than ?role + expect(Search.compare(exactMatch.rank.search, existsMatch.rank.search)).toBeLessThan(0) + }) + + it('ranks search constraints with more exact values higher', () => { + let trie = new Trie() + let twoValues = parse('users?a=1&a=2') + let oneValue = parse('users?a=1') + trie.insert(twoValues, twoValues) + trie.insert(oneValue, oneValue) + + let matches = searchAll(trie, new URL('https://example.com/users?a=1&a=2')) + expect(matches.length).toBe(2) + + let twoValuesMatch = matches.find((m) => m.data === twoValues)! + let oneValueMatch = matches.find((m) => m.data === oneValue)! + + // ?a=1&a=2 is more specific than ?a=1 + expect(Search.compare(twoValuesMatch.rank.search, oneValueMatch.rank.search)).toBeLessThan(0) + }) + + it('ranks search constraints with non-empty check higher than existence check', () => { + let trie = new Trie() + let nonEmpty = parse('users?q=') + let exists = parse('users?q') + trie.insert(nonEmpty, nonEmpty) + trie.insert(exists, exists) + + let matches = searchAll(trie, new URL('https://example.com/users?q=search')) + expect(matches.length).toBe(2) + + let nonEmptyMatch = matches.find((m) => m.data === nonEmpty)! + let existsMatch = matches.find((m) => m.data === exists)! + + // ?q= is more specific than ?q + expect(Search.compare(nonEmptyMatch.rank.search, existsMatch.rank.search)).toBeLessThan(0) + }) + + it('ranks search constraints with more parameters higher when value counts are equal', () => { + let trie = new Trie() + let twoParams = parse('users?a=1&b=1') + let oneParam = parse('users?a=1') + trie.insert(twoParams, twoParams) + trie.insert(oneParam, oneParam) + + let matches = searchAll(trie, new URL('https://example.com/users?a=1&b=1')) + expect(matches.length).toBe(2) + + let twoParamsMatch = matches.find((m) => m.data === twoParams)! + let oneParamMatch = matches.find((m) => m.data === oneParam)! + + // ?a=1&b=1 is more specific than ?a=1 + expect(Search.compare(twoParamsMatch.rank.search, oneParamMatch.rank.search)).toBeLessThan(0) + }) + + it('matches pattern with search constraints only when URL has matching params', () => { + let trie = new Trie() + let withSearch = parse('users?admin=true') + let withoutSearch = parse('users') + trie.insert(withSearch, withSearch) + trie.insert(withoutSearch, withoutSearch) + + // URL without the required param only matches the pattern without search constraints + let matchesWithout = searchAll(trie, new URL('https://example.com/users')) + expect(matchesWithout.length).toBe(1) + expect(matchesWithout[0]?.data).toBe(withoutSearch) + + // URL with the required param matches both patterns + let matchesWith = searchAll(trie, new URL('https://example.com/users?admin=true')) + expect(matchesWith.length).toBe(2) + }) }) }) diff --git a/packages/route-pattern/src/lib2/matchers/trie/trie.ts b/packages/route-pattern/src/lib2/matchers/trie/trie.ts index abb95cde5da..070e6166ff3 100644 --- a/packages/route-pattern/src/lib2/matchers/trie/trie.ts +++ b/packages/route-pattern/src/lib2/matchers/trie/trie.ts @@ -1,12 +1,11 @@ import { RegExp_escape } from '../../es2025.ts' import * as RoutePattern from '../../route-pattern/index.ts' import type { Params } from '../matcher.ts' -import type * as Rank from './rank.ts' const SEPARATORS = ['', '.', '', '/'] const NOT_SEPARATORS = [/.*/g, /[^.]/g, /.*/g, /[^/]/g] -const RANK: Record = { +const RANK: Record = { skip: '3', wildcard: '2', variable: '1', @@ -14,13 +13,18 @@ const RANK: Record = { } type Value = { + searchConstraints: RoutePattern.Search.Constraints paramNames: Array paramIndices: Set data: data } export type Match = { - rank: Array + rank: { + hierarchical: Array + // todo: insert only cost, so maybe consider pre-computing search "rank" + search: RoutePattern.Search.Constraints + } params: Params data: data } @@ -29,8 +33,8 @@ export class Trie { static: Record | undefined> = {} variable: Map }> = new Map() wildcard: Map }> = new Map() + values: Array> = [] next?: Trie - value?: Value insert(pattern: RoutePattern.AST, data: data) { type State = { @@ -41,6 +45,7 @@ export class Trie { for (let variant of RoutePattern.variants(pattern)) { let value: Value = { + searchConstraints: pattern.search, paramNames: RoutePattern.paramNames(pattern), paramIndices: variant.paramIndices, data, @@ -97,7 +102,7 @@ export class Trie { state.trie = next } - state.trie.value = value + state.trie.values.push(value) } } @@ -128,12 +133,16 @@ export class Trie { let state = stack.pop()! if (state.partIndex === query.length) { - let { value } = state.trie - if (value) { - yield { - rank: state.rank, - params: params(value.paramNames, value.paramIndices, state.paramValues), - data: value.data, + for (let value of state.trie.values) { + if (RoutePattern.Search.match(url.searchParams, value.searchConstraints)) { + yield { + rank: { + hierarchical: state.rank, + search: value.searchConstraints, + }, + params: params(value.paramNames, value.paramIndices, state.paramValues), + data: value.data, + } } } continue @@ -290,7 +299,7 @@ function dynamicMatch( match: RegExpExecArray, separator: string, notSeparator: RegExp, -): { paramValues: Array; rank: Rank.Type } { +): { paramValues: Array; rank: Array } { let paramValues: Array = [] let segmentRank = '' for (let group in match.indices?.groups) { diff --git a/packages/route-pattern/src/lib2/route-pattern/ast.ts b/packages/route-pattern/src/lib2/route-pattern/ast.ts index 287c9cdd1a2..f3679570c4f 100644 --- a/packages/route-pattern/src/lib2/route-pattern/ast.ts +++ b/packages/route-pattern/src/lib2/route-pattern/ast.ts @@ -6,5 +6,5 @@ export type AST = { hostname: Part.AST | undefined port: string | undefined pathname: Part.AST | undefined - search: Search.Constraints | undefined + search: Search.Constraints } diff --git a/packages/route-pattern/src/lib2/route-pattern/index.ts b/packages/route-pattern/src/lib2/route-pattern/index.ts index cdb289259d8..31255db7f41 100644 --- a/packages/route-pattern/src/lib2/route-pattern/index.ts +++ b/packages/route-pattern/src/lib2/route-pattern/index.ts @@ -3,3 +3,5 @@ export { join } from './join.ts' export { paramNames } from './param-names.ts' export { parse } from './parse.ts' export { variants } from './variants.ts' + +export * as Search from './search.ts' diff --git a/packages/route-pattern/src/lib2/route-pattern/parse.test.ts b/packages/route-pattern/src/lib2/route-pattern/parse.test.ts index 14aef21614a..e7289950328 100644 --- a/packages/route-pattern/src/lib2/route-pattern/parse.test.ts +++ b/packages/route-pattern/src/lib2/route-pattern/parse.test.ts @@ -17,7 +17,7 @@ describe('parse', () => { paramNames: ['id'], optionals: new Map(), }, - search: undefined, + search: new Map(), }) }) @@ -43,7 +43,7 @@ describe('parse', () => { paramNames: ['id'], optionals: new Map(), }, - search: undefined, + search: new Map(), }) }) @@ -62,7 +62,7 @@ describe('parse', () => { paramNames: [], optionals: new Map(), }, - search: undefined, + search: new Map(), }) }) }) diff --git a/packages/route-pattern/src/lib2/route-pattern/parse.ts b/packages/route-pattern/src/lib2/route-pattern/parse.ts index 7c7a9c8db8e..e065c6a426f 100644 --- a/packages/route-pattern/src/lib2/route-pattern/parse.ts +++ b/packages/route-pattern/src/lib2/route-pattern/parse.ts @@ -9,7 +9,7 @@ export function parse(source: string): AST { hostname: undefined, port: undefined, pathname: undefined, - search: undefined, + search: new Map(), } let { protocol, hostname, port, pathname, search } = split(source) @@ -29,5 +29,6 @@ export function parse(source: string): AST { if (search && search[0] !== search[1]) { ast.search = Search.parse(source.slice(...search)) } + return ast } diff --git a/packages/route-pattern/src/lib2/route-pattern/search.ts b/packages/route-pattern/src/lib2/route-pattern/search.ts index 12f5ee65c0b..3744323799e 100644 --- a/packages/route-pattern/src/lib2/route-pattern/search.ts +++ b/packages/route-pattern/src/lib2/route-pattern/search.ts @@ -35,9 +35,6 @@ export function parse(search: string): Constraints { return constraints } -// todo: URLSearchParams.get() will treat `+` as spaces!!! -// should we account for that? probably yes so that we can compare apples-to-apples in `match` - export function match(params: URLSearchParams, constraints: Constraints): boolean { for (let [name, constraint] of constraints) { if (constraint === null) { @@ -76,3 +73,51 @@ export function join(a: Constraints | undefined, b: Constraints | undefined): Co } return result } + +export function equal(a: Constraints, b: Constraints): boolean { + if (a.size !== b.size) return false + for (let [name, aConstraint] of a) { + let bConstraint = b.get(name) + if (aConstraint === null) { + if (bConstraint !== null) return false + continue + } + if (bConstraint === null || bConstraint === undefined) return false + if (aConstraint.size !== bConstraint.size) return false + for (let value of aConstraint) { + if (!bConstraint.has(value)) return false + } + } + return true +} + +type Rank = [value: number, assigned: number, key: number] + +export function compare(a: Constraints, b: Constraints): number { + let aRank = rank(a) + let bRank = rank(b) + for (let i = 0; i < aRank.length; i++) { + if (aRank[i] !== bRank[i]) return aRank[i] - bRank[i] + } + return 0 +} + +function rank(constraints: Constraints): Rank { + let exactValue = 0 + let anyValue = 0 + let key = 0 + + for (let constraint of constraints.values()) { + if (constraint === null) { + key -= 1 + continue + } + if (constraint.size === 0) { + anyValue -= 1 + continue + } + exactValue -= constraint.size + } + + return [exactValue, anyValue, key] +} From 898a6fe66781c0811d827d96a216884e3bc02f14 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 18 Dec 2025 10:51:51 -0500 Subject: [PATCH 48/54] search ranking in demo --- packages/route-pattern/src/lib2/demo.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/route-pattern/src/lib2/demo.ts b/packages/route-pattern/src/lib2/demo.ts index d0d6a0651a9..2ba63ff4171 100644 --- a/packages/route-pattern/src/lib2/demo.ts +++ b/packages/route-pattern/src/lib2/demo.ts @@ -51,6 +51,15 @@ addRoute('files/:folder/summary', 'Dynamic Folder') addRoute('files/:folder/:page', 'Dynamic Folder + Page') addRoute('files/*path', 'Files Wildcard') +// ---------------------------------------------------------------------------- +// Search constraint patterns (for search ranking demo) +// ---------------------------------------------------------------------------- +addRoute('search?q=react&q=hooks', 'Multi-Value Search') +addRoute('search?q=react', 'Exact Value Search') +addRoute('search?q=', 'Non-Empty Search') +addRoute('search?q', 'Has Query Param') +addRoute('search', 'Basic Search') + // ============================================================================ // Test URLs // ============================================================================ @@ -73,6 +82,10 @@ let testCases: Array<{ url: string; description: string }> = [ url: 'https://example.com/files/report-2024/summary', description: 'Many matches: static > inline var > full var > wildcard', }, + { + url: 'https://example.com/search?q=react&q=hooks', + description: 'Search ranking: multi-value > exact > non-empty > exists > none', + }, ] // ============================================================================ From d25263e92c4ee55fd5b08376664cd3df8d2f3fdb Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 18 Dec 2025 12:03:52 -0500 Subject: [PATCH 49/54] derive paramNames directly in trie.ts --- packages/route-pattern/src/lib2/matchers/trie/trie.ts | 11 ++++++++++- .../route-pattern/src/lib2/route-pattern/index.ts | 1 - .../src/lib2/route-pattern/param-names.ts | 9 --------- 3 files changed, 10 insertions(+), 11 deletions(-) delete mode 100644 packages/route-pattern/src/lib2/route-pattern/param-names.ts diff --git a/packages/route-pattern/src/lib2/matchers/trie/trie.ts b/packages/route-pattern/src/lib2/matchers/trie/trie.ts index 070e6166ff3..547d018d183 100644 --- a/packages/route-pattern/src/lib2/matchers/trie/trie.ts +++ b/packages/route-pattern/src/lib2/matchers/trie/trie.ts @@ -42,11 +42,12 @@ export class Trie { segmentIndex: number trie: Trie } + let paramNames = toParamNames(pattern) for (let variant of RoutePattern.variants(pattern)) { let value: Value = { searchConstraints: pattern.search, - paramNames: RoutePattern.paramNames(pattern), + paramNames, paramIndices: variant.paramIndices, data, } @@ -317,3 +318,11 @@ function dynamicMatch( rank: segmentRank.split(separator), } } + +export function toParamNames(pattern: RoutePattern.AST): Array { + let paramNames: Array = [] + if (pattern.protocol) paramNames.push(...pattern.protocol.paramNames) + if (pattern.hostname) paramNames.push(...pattern.hostname.paramNames) + if (pattern.pathname) paramNames.push(...pattern.pathname.paramNames) + return paramNames +} diff --git a/packages/route-pattern/src/lib2/route-pattern/index.ts b/packages/route-pattern/src/lib2/route-pattern/index.ts index 31255db7f41..4f9d8de8f10 100644 --- a/packages/route-pattern/src/lib2/route-pattern/index.ts +++ b/packages/route-pattern/src/lib2/route-pattern/index.ts @@ -1,6 +1,5 @@ export type { AST } from './ast.ts' export { join } from './join.ts' -export { paramNames } from './param-names.ts' export { parse } from './parse.ts' export { variants } from './variants.ts' diff --git a/packages/route-pattern/src/lib2/route-pattern/param-names.ts b/packages/route-pattern/src/lib2/route-pattern/param-names.ts deleted file mode 100644 index e6af70c219d..00000000000 --- a/packages/route-pattern/src/lib2/route-pattern/param-names.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { AST } from './ast.ts' - -export function paramNames(ast: AST): Array { - let paramNames: Array = [] - if (ast.protocol) paramNames.push(...ast.protocol.paramNames) - if (ast.hostname) paramNames.push(...ast.hostname.paramNames) - if (ast.pathname) paramNames.push(...ast.pathname.paramNames) - return paramNames -} From 6db4b176c43a9a493b4393d1d9fb1dafc1f8ec4b Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 18 Dec 2025 15:01:20 -0500 Subject: [PATCH 50/54] unnamed wilcards get '*' as their name --- .../route-pattern/src/lib2/route-pattern/join.ts | 2 +- .../route-pattern/src/lib2/route-pattern/part/ast.ts | 3 +-- .../src/lib2/route-pattern/part/parse.ts | 12 ++++-------- .../src/lib2/route-pattern/part/variants.ts | 4 +--- 4 files changed, 7 insertions(+), 14 deletions(-) diff --git a/packages/route-pattern/src/lib2/route-pattern/join.ts b/packages/route-pattern/src/lib2/route-pattern/join.ts index c503308ea79..42ea7809641 100644 --- a/packages/route-pattern/src/lib2/route-pattern/join.ts +++ b/packages/route-pattern/src/lib2/route-pattern/join.ts @@ -40,7 +40,7 @@ function joinPathname(a: Part.AST | undefined, b: Part.AST | undefined): Part.AS if (token.type === '*') { tokens.push({ ...token, - nameIndex: token.nameIndex ? token.nameIndex + a.paramNames.length : undefined, + nameIndex: token.nameIndex + a.paramNames.length, }) return } diff --git a/packages/route-pattern/src/lib2/route-pattern/part/ast.ts b/packages/route-pattern/src/lib2/route-pattern/part/ast.ts index 54f1960a58e..5b1236d2837 100644 --- a/packages/route-pattern/src/lib2/route-pattern/part/ast.ts +++ b/packages/route-pattern/src/lib2/route-pattern/part/ast.ts @@ -7,5 +7,4 @@ export type AST = { type Token = | { type: 'text'; text: string } | { type: '(' | ')' } - | { type: ':'; nameIndex: number } - | { type: '*'; nameIndex: number | undefined } + | { type: ':' | '*'; nameIndex: number } diff --git a/packages/route-pattern/src/lib2/route-pattern/part/parse.ts b/packages/route-pattern/src/lib2/route-pattern/part/parse.ts index 553281d4530..1ea4b3c9963 100644 --- a/packages/route-pattern/src/lib2/route-pattern/part/parse.ts +++ b/packages/route-pattern/src/lib2/route-pattern/part/parse.ts @@ -62,14 +62,10 @@ export function parse(source: string, span?: Span): AST { // wildcard if (char === '*') { i += 1 - let name = identifierRE.exec(source.slice(i, span[1]))?.[0] - if (name) { - ast.tokens.push({ type: '*', nameIndex: ast.paramNames.length }) - ast.paramNames.push(name) - i += name.length - } else { - ast.tokens.push({ type: '*', nameIndex: undefined }) - } + let name = identifierRE.exec(source.slice(i, span[1]))?.[0] ?? '*' + ast.tokens.push({ type: '*', nameIndex: ast.paramNames.length }) + ast.paramNames.push(name) + i += name.length continue } diff --git a/packages/route-pattern/src/lib2/route-pattern/part/variants.ts b/packages/route-pattern/src/lib2/route-pattern/part/variants.ts index b112835edce..16e50b482da 100644 --- a/packages/route-pattern/src/lib2/route-pattern/part/variants.ts +++ b/packages/route-pattern/src/lib2/route-pattern/part/variants.ts @@ -41,9 +41,7 @@ export function variants(ast: AST): Array { if (token.type === '*') { variant.key += '{*}' - if (token.nameIndex !== undefined) { - variant.paramIndices.push(token.nameIndex) - } + variant.paramIndices.push(token.nameIndex) q.push({ index: index + 1, variant }) continue } From 423e3d8742f66a790ca79f253485d22be7440ad1 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 18 Dec 2025 15:03:40 -0500 Subject: [PATCH 51/54] remove unused Part.toRegExp --- .../src/lib2/route-pattern/part/index.ts | 1 - .../lib2/route-pattern/part/to-regexp.test.ts | 67 ------------------- .../src/lib2/route-pattern/part/to-regexp.ts | 42 ------------ 3 files changed, 110 deletions(-) delete mode 100644 packages/route-pattern/src/lib2/route-pattern/part/to-regexp.test.ts delete mode 100644 packages/route-pattern/src/lib2/route-pattern/part/to-regexp.ts diff --git a/packages/route-pattern/src/lib2/route-pattern/part/index.ts b/packages/route-pattern/src/lib2/route-pattern/part/index.ts index 236a4ae93de..c64c722789d 100644 --- a/packages/route-pattern/src/lib2/route-pattern/part/index.ts +++ b/packages/route-pattern/src/lib2/route-pattern/part/index.ts @@ -1,4 +1,3 @@ export type { AST } from './ast.ts' export { parse } from './parse.ts' -export { toRegExp } from './to-regexp.ts' export { variants, type Variant } from './variants.ts' diff --git a/packages/route-pattern/src/lib2/route-pattern/part/to-regexp.test.ts b/packages/route-pattern/src/lib2/route-pattern/part/to-regexp.test.ts deleted file mode 100644 index a6cfeca4075..00000000000 --- a/packages/route-pattern/src/lib2/route-pattern/part/to-regexp.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { parse } from './parse.ts' -import { toRegExp } from './to-regexp.ts' - -describe('toRegExp', () => { - it('converts an AST to a regular expression', () => { - let source = 'api/(v:major(.:minor)/)run' - let ast = parse(source) - let paramValueRE = /[^/]+/ - let result = toRegExp(ast, paramValueRE) - - // The regex should match the full pattern - expect('api/v1.2/run').toMatch(result) - expect('api/v1/run').toMatch(result) - expect('api/run').toMatch(result) - - // Should not match patterns that don't fit - expect('api/').not.toMatch(result) - expect('api/v1.2/walk').not.toMatch(result) - }) - - it('handles static text', () => { - let source = 'hello/world' - let ast = parse(source) - let paramValueRE = /[^/]+/ - let result = toRegExp(ast, paramValueRE) - - expect('hello/world').toMatch(result) - expect('hello/there').not.toMatch(result) - }) - - it('handles parameters', () => { - let source = 'users/:id' - let ast = parse(source) - let paramValueRE = /[^/]+/ - let result = toRegExp(ast, paramValueRE) - - expect('users/123').toMatch(result) - expect('users/abc').toMatch(result) - expect('users/').not.toMatch(result) - expect('users/123/posts').not.toMatch(result) - }) - - it('handles wildcards', () => { - let source = 'files/*' - let ast = parse(source) - let paramValueRE = /[^/]+/ - let result = toRegExp(ast, paramValueRE) - - expect('files/anything').toMatch(result) - expect('files/path/to/file').toMatch(result) - expect('files/').toMatch(result) - }) - - it('escapes special regex characters in static text', () => { - let source = 'api/v1.0/users/:id/(notes)' - let ast = parse(source) - let paramValueRE = /[^/]+/ - let result = toRegExp(ast, paramValueRE) - - // The literal '.' and parentheses should be escaped - expect('api/v1.0/users/123/notes').toMatch(result) - expect('api/v1.0/users/123/').toMatch(result) - expect('api/v1X0/users/123/notes').not.toMatch(result) // '.' shouldn't match any char - }) -}) diff --git a/packages/route-pattern/src/lib2/route-pattern/part/to-regexp.ts b/packages/route-pattern/src/lib2/route-pattern/part/to-regexp.ts deleted file mode 100644 index f0971ae7b63..00000000000 --- a/packages/route-pattern/src/lib2/route-pattern/part/to-regexp.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { RegExp_escape } from '../../es2025.ts' -import type { AST } from './ast.ts' - -export function toRegExp(ast: AST, paramValueRE: RegExp): RegExp { - let source = toRegExpSource(ast, paramValueRE) - return new RegExp('^' + source + '$') -} - -export function toRegExpSource(ast: AST, paramValueRE: RegExp): string { - let source = '' - - for (let token of ast.tokens) { - if (token.type === '(') { - source += `(?:` - continue - } - - if (token.type === ')') { - source += ')?' - continue - } - - if (token.type === ':') { - source += `(${paramValueRE.source})` - continue - } - - if (token.type === '*') { - source += token.nameIndex === undefined ? '(?:.*)' : '(.*)' - continue - } - - if (token.type === 'text') { - source += RegExp_escape(token.text) - continue - } - - // todo: make this a type error if `token.type` is not `never` using a custom error - throw new Error(`internal: unrecognized token type '${token.type}'`) - } - return source -} From 856a30594b514d508807de8c7ce51c3e8b8c3eae Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 18 Dec 2025 15:13:05 -0500 Subject: [PATCH 52/54] ParseError --- packages/route-pattern/package.json | 1 + .../route-pattern/src/lib2/errors.test.ts | 23 +++++++++++++++++++ packages/route-pattern/src/lib2/errors.ts | 18 +++++++++++++++ packages/route-pattern/src/lib2/index.ts | 1 + .../src/lib2/route-pattern/part/parse.ts | 9 ++++---- pnpm-lock.yaml | 13 +++++++++++ 6 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 packages/route-pattern/src/lib2/errors.test.ts create mode 100644 packages/route-pattern/src/lib2/errors.ts diff --git a/packages/route-pattern/package.json b/packages/route-pattern/package.json index 60310448a3d..78bf9b05c77 100644 --- a/packages/route-pattern/package.json +++ b/packages/route-pattern/package.json @@ -36,6 +36,7 @@ "@ark/attest": "^0.49.0", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", + "dedent": "^1.7.1", "find-my-way": "^9.1.0", "path-to-regexp": "^8.2.0", "typescript": "catalog:", diff --git a/packages/route-pattern/src/lib2/errors.test.ts b/packages/route-pattern/src/lib2/errors.test.ts new file mode 100644 index 00000000000..2d4291cb769 --- /dev/null +++ b/packages/route-pattern/src/lib2/errors.test.ts @@ -0,0 +1,23 @@ +import dedent from 'dedent' +import { describe, expect, it } from 'vitest' + +import { ParseError } from './errors.ts' + +describe('ParseError', () => { + it('exposes type, source, and index properties', () => { + let error = new ParseError('unmatched (', 'foo(bar', 3) + expect(error.type).toBe('unmatched (') + expect(error.source).toBe('foo(bar') + expect(error.index).toBe(3) + }) + + it('shows caret under the problematic index', () => { + let error = new ParseError('unmatched (', 'api/(v:major', 4) + expect(error.toString()).toBe(dedent` + ParseError: unmatched ( + + api/(v:major + ^ + `) + }) +}) diff --git a/packages/route-pattern/src/lib2/errors.ts b/packages/route-pattern/src/lib2/errors.ts new file mode 100644 index 00000000000..82c6efc1ad4 --- /dev/null +++ b/packages/route-pattern/src/lib2/errors.ts @@ -0,0 +1,18 @@ +type ParseErrorType = 'unmatched (' | 'unmatched )' | 'missing variable name' | 'dangling escape' + +export class ParseError extends Error { + type: ParseErrorType + source: string + index: number + + constructor(type: ParseErrorType, source: string, index: number) { + let underline = ' '.repeat(index) + '^' + let message = `${type}\n\n${source}\n${underline}` + + super(message) + this.name = 'ParseError' + this.type = type + this.source = source + this.index = index + } +} diff --git a/packages/route-pattern/src/lib2/index.ts b/packages/route-pattern/src/lib2/index.ts index e8db6c76f16..ab6f38563d6 100644 --- a/packages/route-pattern/src/lib2/index.ts +++ b/packages/route-pattern/src/lib2/index.ts @@ -1 +1,2 @@ export { TrieMatcher } from './matchers/index.ts' +export { ParseError } from './errors.ts' diff --git a/packages/route-pattern/src/lib2/route-pattern/part/parse.ts b/packages/route-pattern/src/lib2/route-pattern/part/parse.ts index 1ea4b3c9963..1e77ad729bc 100644 --- a/packages/route-pattern/src/lib2/route-pattern/part/parse.ts +++ b/packages/route-pattern/src/lib2/route-pattern/part/parse.ts @@ -1,3 +1,4 @@ +import { ParseError } from '../../errors.ts' import type { Span } from '../span' import type { AST } from './ast' @@ -38,7 +39,7 @@ export function parse(source: string, span?: Span): AST { if (char === ')') { let begin = optionalStack.pop() if (begin === undefined) { - throw new Error(`unmatched ) at ${i}`) + throw new ParseError('unmatched )', source, i) } ast.optionals.set(begin, ast.tokens.length) ast.tokens.push({ type: char }) @@ -51,7 +52,7 @@ export function parse(source: string, span?: Span): AST { i += 1 let name = identifierRE.exec(source.slice(i, span[1]))?.[0] if (!name) { - throw new Error(`missing variable name at ${i}`) + throw new ParseError('missing variable name', source, i) } ast.tokens.push({ type: ':', nameIndex: ast.paramNames.length }) ast.paramNames.push(name) @@ -72,7 +73,7 @@ export function parse(source: string, span?: Span): AST { // escaped char if (char === '\\') { if (i + 1 === span[1]) { - throw new Error(`dangling escape at ${i}`) + throw new ParseError('dangling escape', source, i) } let text = source.slice(i, i + 2) appendText(text) @@ -85,7 +86,7 @@ export function parse(source: string, span?: Span): AST { i += 1 } if (optionalStack.length > 0) { - throw new Error(`unmatched ( at ${optionalStack.at(-1)!}`) + throw new ParseError('unmatched (', source, optionalStack.at(-1)!) } return ast diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24a33e9dbc3..fedeacafe4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -799,6 +799,9 @@ importers: '@typescript/native-preview': specifier: 'catalog:' version: 7.0.0-dev.20251125.1 + dedent: + specifier: ^1.7.1 + version: 1.7.1 find-my-way: specifier: ^9.1.0 version: 9.3.0 @@ -2602,6 +2605,14 @@ packages: supports-color: optional: true + dedent@1.7.1: + resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -5329,6 +5340,8 @@ snapshots: dependencies: ms: 2.1.3 + dedent@1.7.1: {} + deep-eql@5.0.2: {} deep-extend@0.6.0: {} From 330b466239066502a1a8b27570e3046ed64c757a Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 18 Dec 2025 15:26:46 -0500 Subject: [PATCH 53/54] reorg --- packages/route-pattern/src/lib2/demo.ts | 132 ------------------ .../src/lib2/{route-pattern => }/part/ast.ts | 0 .../lib2/{route-pattern => }/part/index.ts | 0 .../{route-pattern => }/part/parse.test.ts | 0 .../lib2/{route-pattern => }/part/parse.ts | 6 +- .../{route-pattern => }/part/variants.test.ts | 0 .../lib2/{route-pattern => }/part/variants.ts | 0 .../src/lib2/route-pattern/ast.ts | 2 +- .../src/lib2/route-pattern/join.ts | 2 +- .../src/lib2/route-pattern/parse.ts | 2 +- .../src/lib2/route-pattern/split.ts | 2 +- .../src/lib2/route-pattern/variants.ts | 2 +- .../src/lib2/{route-pattern => }/span.ts | 0 13 files changed, 8 insertions(+), 140 deletions(-) delete mode 100644 packages/route-pattern/src/lib2/demo.ts rename packages/route-pattern/src/lib2/{route-pattern => }/part/ast.ts (100%) rename packages/route-pattern/src/lib2/{route-pattern => }/part/index.ts (100%) rename packages/route-pattern/src/lib2/{route-pattern => }/part/parse.test.ts (100%) rename packages/route-pattern/src/lib2/{route-pattern => }/part/parse.ts (94%) rename packages/route-pattern/src/lib2/{route-pattern => }/part/variants.test.ts (100%) rename packages/route-pattern/src/lib2/{route-pattern => }/part/variants.ts (100%) rename packages/route-pattern/src/lib2/{route-pattern => }/span.ts (100%) diff --git a/packages/route-pattern/src/lib2/demo.ts b/packages/route-pattern/src/lib2/demo.ts deleted file mode 100644 index 2ba63ff4171..00000000000 --- a/packages/route-pattern/src/lib2/demo.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { TrieMatcher } from './matchers/trie/index.ts' -import { parse } from './route-pattern/index.ts' - -// ============================================================================ -// Demo: TrieMatcher Pattern Matching with Ranking -// ============================================================================ -// This demo shows how the TrieMatcher handles multiple patterns, including -// cases where several patterns could match the same URL. The matcher uses -// a ranking system to pick the "best" match: -// - Static segments beat dynamic segments -// - Dynamic segments beat wildcards -// - More specific patterns beat less specific ones -// ============================================================================ - -type RouteData = { name: string; pattern: string } - -let matcher = new TrieMatcher() - -function addRoute(pattern: string, name: string) { - matcher.add(parse(pattern), { name, pattern }) -} - -// ---------------------------------------------------------------------------- -// Simple patterns -// ---------------------------------------------------------------------------- -addRoute('about', 'About') -addRoute('users/:id', 'User Profile') - -// ---------------------------------------------------------------------------- -// Optional segments (generates multiple variants internally) -// ---------------------------------------------------------------------------- -addRoute('api(/v:major(.:minor))/status', 'API Status') - -// ---------------------------------------------------------------------------- -// Wildcards -// ---------------------------------------------------------------------------- -addRoute('*catchall', 'Catch-All') - -// ---------------------------------------------------------------------------- -// Pathological / Advanced patterns -// ---------------------------------------------------------------------------- -addRoute('assets/:name.png', 'PNG Asset') -addRoute('assets/:name.:ext', 'Any Asset') - -// ---------------------------------------------------------------------------- -// Many overlapping patterns (for ranking demo) -// ---------------------------------------------------------------------------- -addRoute('files/report-2024/summary', 'Exact Match') -addRoute('files/report-:year/summary', 'Inline Variable') -addRoute('files/:folder/summary', 'Dynamic Folder') -addRoute('files/:folder/:page', 'Dynamic Folder + Page') -addRoute('files/*path', 'Files Wildcard') - -// ---------------------------------------------------------------------------- -// Search constraint patterns (for search ranking demo) -// ---------------------------------------------------------------------------- -addRoute('search?q=react&q=hooks', 'Multi-Value Search') -addRoute('search?q=react', 'Exact Value Search') -addRoute('search?q=', 'Non-Empty Search') -addRoute('search?q', 'Has Query Param') -addRoute('search', 'Basic Search') - -// ============================================================================ -// Test URLs -// ============================================================================ - -let testCases: Array<{ url: string; description: string }> = [ - // Simple examples - { url: 'https://example.com/about', description: 'Static path' }, - { url: 'https://example.com/users/123', description: 'Dynamic param' }, - - // Interesting examples showing ranking (3+ matches each) - { - url: 'https://example.com/api/v2.1/status', - description: 'Optional segments: same pattern matches twice with different params', - }, - { - url: 'https://example.com/assets/logo.png', - description: 'Specificity: static suffix beats dynamic suffix', - }, - { - url: 'https://example.com/files/report-2024/summary', - description: 'Many matches: static > inline var > full var > wildcard', - }, - { - url: 'https://example.com/search?q=react&q=hooks', - description: 'Search ranking: multi-value > exact > non-empty > exists > none', - }, -] - -// ============================================================================ -// Run the demo -// ============================================================================ - -console.log('='.repeat(80)) -console.log('TrieMatcher Demo') -console.log('='.repeat(80)) -console.log() -console.log(`Loaded ${matcher.size} patterns`) -console.log() - -for (let { url, description } of testCases) { - console.log('-'.repeat(80)) - console.log(`📍 ${description}`) - console.log(` URL: ${url}`) - - let results = matcher.matchAll(new URL(url)) - - if (results.length === 0) { - console.log(` ❌ No match`) - console.log() - } else { - console.log( - ` Found ${results.length} match${results.length > 1 ? 'es' : ''} (ranked best to worst):`, - ) - console.log() - results.forEach((result, i) => { - let prefix = i === 0 ? '✅' : ' ' - console.log(` ${prefix} ${i + 1}. "${result.data.name}"`) - console.log(` Pattern: ${result.data.pattern}`) - let paramEntries = Object.entries(result.params).filter(([, v]) => v !== undefined) - if (paramEntries.length > 0) { - console.log(` Params:`, Object.fromEntries(paramEntries)) - } - console.log() - }) - } -} - -console.log('='.repeat(80)) -console.log('Demo complete!') -console.log('='.repeat(80)) diff --git a/packages/route-pattern/src/lib2/route-pattern/part/ast.ts b/packages/route-pattern/src/lib2/part/ast.ts similarity index 100% rename from packages/route-pattern/src/lib2/route-pattern/part/ast.ts rename to packages/route-pattern/src/lib2/part/ast.ts diff --git a/packages/route-pattern/src/lib2/route-pattern/part/index.ts b/packages/route-pattern/src/lib2/part/index.ts similarity index 100% rename from packages/route-pattern/src/lib2/route-pattern/part/index.ts rename to packages/route-pattern/src/lib2/part/index.ts diff --git a/packages/route-pattern/src/lib2/route-pattern/part/parse.test.ts b/packages/route-pattern/src/lib2/part/parse.test.ts similarity index 100% rename from packages/route-pattern/src/lib2/route-pattern/part/parse.test.ts rename to packages/route-pattern/src/lib2/part/parse.test.ts diff --git a/packages/route-pattern/src/lib2/route-pattern/part/parse.ts b/packages/route-pattern/src/lib2/part/parse.ts similarity index 94% rename from packages/route-pattern/src/lib2/route-pattern/part/parse.ts rename to packages/route-pattern/src/lib2/part/parse.ts index 1e77ad729bc..7edea0d37e6 100644 --- a/packages/route-pattern/src/lib2/route-pattern/part/parse.ts +++ b/packages/route-pattern/src/lib2/part/parse.ts @@ -1,6 +1,6 @@ -import { ParseError } from '../../errors.ts' -import type { Span } from '../span' -import type { AST } from './ast' +import { ParseError } from '../errors.ts' +import type { Span } from '../span.ts' +import type { AST } from './ast.ts' const identifierRE = /^[a-zA-Z_$][a-zA-Z_$0-9]*/ diff --git a/packages/route-pattern/src/lib2/route-pattern/part/variants.test.ts b/packages/route-pattern/src/lib2/part/variants.test.ts similarity index 100% rename from packages/route-pattern/src/lib2/route-pattern/part/variants.test.ts rename to packages/route-pattern/src/lib2/part/variants.test.ts diff --git a/packages/route-pattern/src/lib2/route-pattern/part/variants.ts b/packages/route-pattern/src/lib2/part/variants.ts similarity index 100% rename from packages/route-pattern/src/lib2/route-pattern/part/variants.ts rename to packages/route-pattern/src/lib2/part/variants.ts diff --git a/packages/route-pattern/src/lib2/route-pattern/ast.ts b/packages/route-pattern/src/lib2/route-pattern/ast.ts index f3679570c4f..923f29c4500 100644 --- a/packages/route-pattern/src/lib2/route-pattern/ast.ts +++ b/packages/route-pattern/src/lib2/route-pattern/ast.ts @@ -1,4 +1,4 @@ -import type * as Part from './part/index.ts' +import type * as Part from '../part/index.ts' import type * as Search from './search.ts' export type AST = { diff --git a/packages/route-pattern/src/lib2/route-pattern/join.ts b/packages/route-pattern/src/lib2/route-pattern/join.ts index 42ea7809641..d2589acb224 100644 --- a/packages/route-pattern/src/lib2/route-pattern/join.ts +++ b/packages/route-pattern/src/lib2/route-pattern/join.ts @@ -1,5 +1,5 @@ import type { AST } from './ast.ts' -import type * as Part from './part/index.ts' +import type * as Part from '../part/index.ts' import * as Search from './search.ts' export function join(a: AST, b: AST): AST { diff --git a/packages/route-pattern/src/lib2/route-pattern/parse.ts b/packages/route-pattern/src/lib2/route-pattern/parse.ts index e065c6a426f..460976a35f9 100644 --- a/packages/route-pattern/src/lib2/route-pattern/parse.ts +++ b/packages/route-pattern/src/lib2/route-pattern/parse.ts @@ -1,6 +1,6 @@ import type { AST } from './ast.ts' import { split } from './split.ts' -import * as Part from './part/index.ts' +import * as Part from '../part/index.ts' import * as Search from './search.ts' export function parse(source: string): AST { diff --git a/packages/route-pattern/src/lib2/route-pattern/split.ts b/packages/route-pattern/src/lib2/route-pattern/split.ts index 917cf0ab962..996502cab09 100644 --- a/packages/route-pattern/src/lib2/route-pattern/split.ts +++ b/packages/route-pattern/src/lib2/route-pattern/split.ts @@ -1,4 +1,4 @@ -import type { Span } from './span' +import type { Span } from '../span' export interface SplitResult { protocol: Span | undefined diff --git a/packages/route-pattern/src/lib2/route-pattern/variants.ts b/packages/route-pattern/src/lib2/route-pattern/variants.ts index 528e4e8b27d..57dbd05a717 100644 --- a/packages/route-pattern/src/lib2/route-pattern/variants.ts +++ b/packages/route-pattern/src/lib2/route-pattern/variants.ts @@ -1,5 +1,5 @@ import type { AST } from './ast.ts' -import * as Part from './part/index.ts' +import * as Part from '../part/index.ts' type Tuple4 = [protocol: T, hostname: T, port: T, pathname: T] diff --git a/packages/route-pattern/src/lib2/route-pattern/span.ts b/packages/route-pattern/src/lib2/span.ts similarity index 100% rename from packages/route-pattern/src/lib2/route-pattern/span.ts rename to packages/route-pattern/src/lib2/span.ts From 2eb6773c24947b79e9e5de0debab13920d39d8c4 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 19 Dec 2025 10:34:38 -0500 Subject: [PATCH 54/54] rename "part" to "part pattern" --- .../route-pattern/src/lib2/{part => part-pattern}/ast.ts | 0 .../src/lib2/{part => part-pattern}/index.ts | 0 .../src/lib2/{part => part-pattern}/parse.test.ts | 4 +--- .../src/lib2/{part => part-pattern}/parse.ts | 0 .../src/lib2/{part => part-pattern}/variants.test.ts | 0 .../src/lib2/{part => part-pattern}/variants.ts | 0 packages/route-pattern/src/lib2/route-pattern/ast.ts | 8 ++++---- packages/route-pattern/src/lib2/route-pattern/join.ts | 7 +++++-- packages/route-pattern/src/lib2/route-pattern/parse.ts | 8 ++++---- packages/route-pattern/src/lib2/route-pattern/variants.ts | 8 ++++---- 10 files changed, 18 insertions(+), 17 deletions(-) rename packages/route-pattern/src/lib2/{part => part-pattern}/ast.ts (100%) rename packages/route-pattern/src/lib2/{part => part-pattern}/index.ts (100%) rename packages/route-pattern/src/lib2/{part => part-pattern}/parse.test.ts (86%) rename packages/route-pattern/src/lib2/{part => part-pattern}/parse.ts (100%) rename packages/route-pattern/src/lib2/{part => part-pattern}/variants.test.ts (100%) rename packages/route-pattern/src/lib2/{part => part-pattern}/variants.ts (100%) diff --git a/packages/route-pattern/src/lib2/part/ast.ts b/packages/route-pattern/src/lib2/part-pattern/ast.ts similarity index 100% rename from packages/route-pattern/src/lib2/part/ast.ts rename to packages/route-pattern/src/lib2/part-pattern/ast.ts diff --git a/packages/route-pattern/src/lib2/part/index.ts b/packages/route-pattern/src/lib2/part-pattern/index.ts similarity index 100% rename from packages/route-pattern/src/lib2/part/index.ts rename to packages/route-pattern/src/lib2/part-pattern/index.ts diff --git a/packages/route-pattern/src/lib2/part/parse.test.ts b/packages/route-pattern/src/lib2/part-pattern/parse.test.ts similarity index 86% rename from packages/route-pattern/src/lib2/part/parse.test.ts rename to packages/route-pattern/src/lib2/part-pattern/parse.test.ts index 2da5257edde..10cce8251af 100644 --- a/packages/route-pattern/src/lib2/part/parse.test.ts +++ b/packages/route-pattern/src/lib2/part-pattern/parse.test.ts @@ -4,9 +4,7 @@ import { parse } from './parse.ts' describe('parse', () => { it('creates an AST', () => { - let source = 'api/(v:major(.:minor)/)run' - let ast = parse(source) - expect(ast).toEqual({ + expect(parse('api/(v:major(.:minor)/)run')).toEqual({ tokens: [ { type: 'text', text: 'api/' }, { type: '(' }, diff --git a/packages/route-pattern/src/lib2/part/parse.ts b/packages/route-pattern/src/lib2/part-pattern/parse.ts similarity index 100% rename from packages/route-pattern/src/lib2/part/parse.ts rename to packages/route-pattern/src/lib2/part-pattern/parse.ts diff --git a/packages/route-pattern/src/lib2/part/variants.test.ts b/packages/route-pattern/src/lib2/part-pattern/variants.test.ts similarity index 100% rename from packages/route-pattern/src/lib2/part/variants.test.ts rename to packages/route-pattern/src/lib2/part-pattern/variants.test.ts diff --git a/packages/route-pattern/src/lib2/part/variants.ts b/packages/route-pattern/src/lib2/part-pattern/variants.ts similarity index 100% rename from packages/route-pattern/src/lib2/part/variants.ts rename to packages/route-pattern/src/lib2/part-pattern/variants.ts diff --git a/packages/route-pattern/src/lib2/route-pattern/ast.ts b/packages/route-pattern/src/lib2/route-pattern/ast.ts index 923f29c4500..cab204b9bd0 100644 --- a/packages/route-pattern/src/lib2/route-pattern/ast.ts +++ b/packages/route-pattern/src/lib2/route-pattern/ast.ts @@ -1,10 +1,10 @@ -import type * as Part from '../part/index.ts' +import type * as PartPattern from '../part-pattern/index.ts' import type * as Search from './search.ts' export type AST = { - protocol: Part.AST | undefined - hostname: Part.AST | undefined + protocol: PartPattern.AST | undefined + hostname: PartPattern.AST | undefined port: string | undefined - pathname: Part.AST | undefined + pathname: PartPattern.AST | undefined search: Search.Constraints } diff --git a/packages/route-pattern/src/lib2/route-pattern/join.ts b/packages/route-pattern/src/lib2/route-pattern/join.ts index d2589acb224..1abdadf672d 100644 --- a/packages/route-pattern/src/lib2/route-pattern/join.ts +++ b/packages/route-pattern/src/lib2/route-pattern/join.ts @@ -1,5 +1,5 @@ import type { AST } from './ast.ts' -import type * as Part from '../part/index.ts' +import type * as PartPattern from '../part-pattern/index.ts' import * as Search from './search.ts' export function join(a: AST, b: AST): AST { @@ -12,7 +12,10 @@ export function join(a: AST, b: AST): AST { } } -function joinPathname(a: Part.AST | undefined, b: Part.AST | undefined): Part.AST | undefined { +function joinPathname( + a: PartPattern.AST | undefined, + b: PartPattern.AST | undefined, +): PartPattern.AST | undefined { if (a === undefined) return b if (b === undefined) return a diff --git a/packages/route-pattern/src/lib2/route-pattern/parse.ts b/packages/route-pattern/src/lib2/route-pattern/parse.ts index 460976a35f9..24b3503d6e0 100644 --- a/packages/route-pattern/src/lib2/route-pattern/parse.ts +++ b/packages/route-pattern/src/lib2/route-pattern/parse.ts @@ -1,6 +1,6 @@ import type { AST } from './ast.ts' import { split } from './split.ts' -import * as Part from '../part/index.ts' +import * as PartPattern from '../part-pattern/index.ts' import * as Search from './search.ts' export function parse(source: string): AST { @@ -15,16 +15,16 @@ export function parse(source: string): AST { let { protocol, hostname, port, pathname, search } = split(source) if (protocol && protocol[0] !== protocol[1]) { - ast.protocol = Part.parse(source, protocol) + ast.protocol = PartPattern.parse(source, protocol) } if (hostname && hostname[0] !== hostname[1]) { - ast.hostname = Part.parse(source, hostname) + ast.hostname = PartPattern.parse(source, hostname) } if (port && port[0] !== port[1]) { ast.port = source.slice(...port) } if (pathname && pathname[0] !== pathname[1]) { - ast.pathname = Part.parse(source, pathname) + ast.pathname = PartPattern.parse(source, pathname) } if (search && search[0] !== search[1]) { ast.search = Search.parse(source.slice(...search)) diff --git a/packages/route-pattern/src/lib2/route-pattern/variants.ts b/packages/route-pattern/src/lib2/route-pattern/variants.ts index 57dbd05a717..fec2ae96532 100644 --- a/packages/route-pattern/src/lib2/route-pattern/variants.ts +++ b/packages/route-pattern/src/lib2/route-pattern/variants.ts @@ -1,5 +1,5 @@ import type { AST } from './ast.ts' -import * as Part from '../part/index.ts' +import * as PartPattern from '../part-pattern/index.ts' type Tuple4 = [protocol: T, hostname: T, port: T, pathname: T] @@ -9,9 +9,9 @@ type Variant = { } export function* variants(pattern: AST): Generator { - let protocols = pattern.protocol ? Part.variants(pattern.protocol) : [undefined] - let hostnames = pattern.hostname ? Part.variants(pattern.hostname) : [undefined] - let pathnames = pattern.pathname ? Part.variants(pattern.pathname) : [undefined] + let protocols = pattern.protocol ? PartPattern.variants(pattern.protocol) : [undefined] + let hostnames = pattern.hostname ? PartPattern.variants(pattern.hostname) : [undefined] + let pathnames = pattern.pathname ? PartPattern.variants(pattern.pathname) : [undefined] for (let protocol of protocols) { for (let hostname of hostnames) {