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/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/es2025.ts b/packages/route-pattern/src/lib2/es2025.ts new file mode 100644 index 00000000000..81449ae9c6d --- /dev/null +++ b/packages/route-pattern/src/lib2/es2025.ts @@ -0,0 +1,5 @@ +/** + * 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, '\\$&') diff --git a/packages/route-pattern/src/lib2/index.ts b/packages/route-pattern/src/lib2/index.ts new file mode 100644 index 00000000000..ab6f38563d6 --- /dev/null +++ b/packages/route-pattern/src/lib2/index.ts @@ -0,0 +1,2 @@ +export { TrieMatcher } from './matchers/index.ts' +export { ParseError } from './errors.ts' 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..5d4e4492eab --- /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/index.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..7bf261db56b --- /dev/null +++ b/packages/route-pattern/src/lib2/matchers/matcher.ts @@ -0,0 +1,10 @@ +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 + matchAll: (url: URL) => Array<{ params: Params; data: data }> + size: number +} 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..35ad5619d87 --- /dev/null +++ b/packages/route-pattern/src/lib2/matchers/trie/rank.ts @@ -0,0 +1,28 @@ +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): 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] + 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 +} diff --git a/packages/route-pattern/src/lib2/matchers/trie/trie.test.ts b/packages/route-pattern/src/lib2/matchers/trie/trie.test.ts new file mode 100644 index 00000000000..54ef27442b8 --- /dev/null +++ b/packages/route-pattern/src/lib2/matchers/trie/trie.test.ts @@ -0,0 +1,699 @@ +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) { + return [...trie.search(url)] +} + +describe('Trie', () => { + describe('constructor', () => { + it('creates a trie with empty nodes', () => { + let trie = new Trie() + expect(trie.static).toEqual({}) + expect(trie.variable).toEqual(new Map()) + expect(trie.wildcard).toEqual(new Map()) + expect(trie.next).toBe(undefined) + expect(trie.values).toEqual([]) + }) + }) + + describe('insert', () => { + it('inserts a simple static pathname pattern', () => { + let trie = new Trie() + let pattern = parse('users/list') + trie.insert(pattern, null) + + // 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', () => { + let trie = new Trie() + let pattern = parse('users/:id') + trie.insert(pattern, null) + + // 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', () => { + let trie = new Trie() + let pattern = parse('https://example.com/users/:id') + trie.insert(pattern, null) + + // 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 = new Trie() + let pattern = parse('api/(v:major(.:minor)/)run') + trie.insert(pattern, null) + + // 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 + 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 = new Trie() + let pattern1 = parse('users/:id') + let pattern2 = parse('users/admin') + let pattern3 = parse('posts/:id') + + trie.insert(pattern1, null) + trie.insert(pattern2, null) + trie.insert(pattern3, null) + + // 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() + 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 = new Trie() + let pattern = parse('files/*path') + trie.insert(pattern, null) + + // 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() + // 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 = new Trie() + let pattern = parse('assets/images/*file') + trie.insert(pattern, null) + + // 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() + expect(pathnameLevel?.static['assets']?.static['images']?.wildcard.has('{*}')).toBeTruthy() + }) + + it('inserts a pattern with inline wildcard', () => { + let trie = new Trie() + let pattern = parse('files/prefix-*rest') + trie.insert(pattern, null) + + // 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 + expect(pathnameLevel?.static['files']?.wildcard.has('prefix-{*}')).toBeTruthy() + }) + + it('inserts a pattern with anonymous wildcard', () => { + let trie = new Trie() + let pattern = parse('api/*') + trie.insert(pattern, null) + + // 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() + }) + + it('inserts a pattern with wildcard followed by static segment', () => { + let trie = new Trie() + let pattern = parse('files/*path/details') + trie.insert(pattern, null) + + // 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 + expect(pathnameLevel?.static['files']?.wildcard.has('{*}/details')).toBeTruthy() + }) + + it('inserts a pattern with wildcard followed by multiple segments', () => { + let trie = new Trie() + let pattern = parse('files/*path/foo/bar') + trie.insert(pattern, null) + + // 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 + expect(pathnameLevel?.static['files']?.wildcard.has('{*}/foo/bar')).toBeTruthy() + }) + + it('inserts a pattern with wildcard followed by dynamic segment', () => { + let trie = new Trie() + let pattern = parse('files/*path/:id') + trie.insert(pattern, null) + + // 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 ({:}) -> 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?.values[0]).toBeTruthy() + expect(leaf?.values[0]?.data).toBe(data) + expect(leaf?.values[0]?.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?.values[0]).toBeTruthy() + expect(leaf?.values[0]?.paramNames).toEqual(['orgId', 'repoId']) + }) + }) + + describe('search', () => { + it('matches a simple static pathname pattern', () => { + let trie = new Trie() + let pattern = parse('users/list') + trie.insert(pattern, pattern) + + 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', () => { + let trie = new Trie() + let pattern = parse('users/list') + 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 = new Trie() + let pattern = parse('users/:id') + trie.insert(pattern, pattern) + + 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', () => { + let trie = new Trie() + let pattern = parse('https://example.com/users/:id') + trie.insert(pattern, pattern) + + 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', () => { + let trie = new Trie() + let pattern = parse('https://example.com/users') + 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 = new Trie() + let pattern = parse('https://example.com/users') + 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 = new Trie() + let pattern = parse('files/*path') + 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]?.data).toBe(pattern) + expect(matches[0]?.params).toEqual({ path: 'a/b/c' }) + }) + + it('matches multiple patterns with shared prefix', () => { + let trie = new Trie() + let pattern1 = parse('users/:id') + let pattern2 = parse('users/admin') + trie.insert(pattern1, pattern1) + trie.insert(pattern2, 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 + // 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' }) + }) + + 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' }) + }) + + 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) + + let staticMatch = matches.find((m) => m.data === staticPattern) + let dynamicMatch = matches.find((m) => m.data === dynamicPattern) + + expect(staticMatch).toStrictEqual({ + 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: { + 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, + }) + }) + + 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: { + 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, + }) + }) + + 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: { + 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, + }) + }) + + 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: { + 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, + }) + }) + + 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: { + 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, + }) + }) + + 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: { + 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, + }) + }) + + 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.hierarchical.join(',') < lessStaticMatch!.rank.hierarchical.join(','), + ).toBe(true) + + expect(moreStaticMatch).toStrictEqual({ + 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: { + 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, + }) + }) + + 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: { + 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 new file mode 100644 index 00000000000..547d018d183 --- /dev/null +++ b/packages/route-pattern/src/lib2/matchers/trie/trie.ts @@ -0,0 +1,328 @@ +import { RegExp_escape } from '../../es2025.ts' +import * as RoutePattern from '../../route-pattern/index.ts' +import type { Params } from '../matcher.ts' + +const SEPARATORS = ['', '.', '', '/'] +const NOT_SEPARATORS = [/.*/g, /[^.]/g, /.*/g, /[^/]/g] + +const RANK: Record = { + skip: '3', + wildcard: '2', + variable: '1', + static: '0', +} + +type Value = { + searchConstraints: RoutePattern.Search.Constraints + paramNames: Array + paramIndices: Set + data: data +} + +export type Match = { + rank: { + hierarchical: Array + // todo: insert only cost, so maybe consider pre-computing search "rank" + search: RoutePattern.Search.Constraints + } + params: Params + data: data +} + +export class Trie { + static: Record | undefined> = {} + variable: Map }> = new Map() + wildcard: Map }> = new Map() + values: Array> = [] + next?: Trie + + insert(pattern: RoutePattern.AST, data: data) { + type State = { + partIndex: number + segmentIndex: number + trie: Trie + } + let paramNames = toParamNames(pattern) + + for (let variant of RoutePattern.variants(pattern)) { + let value: Value = { + searchConstraints: pattern.search, + paramNames, + 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.values.push(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) { + 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 + } + + 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 [key, { regexp, trie }] of state.trie.variable) { + let match = regexp.exec(segment) + if (match) { + let paramValues = state.paramValues.slice() + 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, + trie, + paramValues, + rank, + }) + } + } + + 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 paramValues = state.paramValues.slice() + 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, + 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: Array } { + let paramValues: Array = [] + let segmentRank = '' + 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), + } +} + +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/part-pattern/ast.ts b/packages/route-pattern/src/lib2/part-pattern/ast.ts new file mode 100644 index 00000000000..5b1236d2837 --- /dev/null +++ b/packages/route-pattern/src/lib2/part-pattern/ast.ts @@ -0,0 +1,10 @@ +export type AST = { + tokens: Array + paramNames: Array + optionals: Map +} + +type Token = + | { type: 'text'; text: string } + | { type: '(' | ')' } + | { type: ':' | '*'; nameIndex: number } diff --git a/packages/route-pattern/src/lib2/part-pattern/index.ts b/packages/route-pattern/src/lib2/part-pattern/index.ts new file mode 100644 index 00000000000..c64c722789d --- /dev/null +++ b/packages/route-pattern/src/lib2/part-pattern/index.ts @@ -0,0 +1,3 @@ +export type { AST } from './ast.ts' +export { parse } from './parse.ts' +export { variants, type Variant } from './variants.ts' diff --git a/packages/route-pattern/src/lib2/part-pattern/parse.test.ts b/packages/route-pattern/src/lib2/part-pattern/parse.test.ts new file mode 100644 index 00000000000..10cce8251af --- /dev/null +++ b/packages/route-pattern/src/lib2/part-pattern/parse.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' + +import { parse } from './parse.ts' + +describe('parse', () => { + it('creates an AST', () => { + expect(parse('api/(v:major(.:minor)/)run')).toEqual({ + 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-pattern/parse.ts b/packages/route-pattern/src/lib2/part-pattern/parse.ts new file mode 100644 index 00000000000..7edea0d37e6 --- /dev/null +++ b/packages/route-pattern/src/lib2/part-pattern/parse.ts @@ -0,0 +1,93 @@ +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]*/ + +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 === undefined) { + throw new ParseError('unmatched )', source, 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 ParseError('missing variable name', source, 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] ?? '*' + ast.tokens.push({ type: '*', nameIndex: ast.paramNames.length }) + ast.paramNames.push(name) + i += name.length + continue + } + + // escaped char + if (char === '\\') { + if (i + 1 === span[1]) { + throw new ParseError('dangling escape', source, 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 ParseError('unmatched (', source, optionalStack.at(-1)!) + } + + return ast +} diff --git a/packages/route-pattern/src/lib2/part-pattern/variants.test.ts b/packages/route-pattern/src/lib2/part-pattern/variants.test.ts new file mode 100644 index 00000000000..f7226ae9a8f --- /dev/null +++ b/packages/route-pattern/src/lib2/part-pattern/variants.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' + +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) + 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/part-pattern/variants.ts b/packages/route-pattern/src/lib2/part-pattern/variants.ts new file mode 100644 index 00000000000..16e50b482da --- /dev/null +++ b/packages/route-pattern/src/lib2/part-pattern/variants.ts @@ -0,0 +1,59 @@ +import type { AST } from './ast' + +export type Variant = { + key: string + paramIndices: Array +} + +export function variants(ast: AST): Array { + let result: Array = [] + + let q: Array<{ index: number; variant: Variant }> = [ + { index: 0, variant: { key: '', paramIndices: [] } }, + ] + 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: structuredClone(variant) }, // exclude optional + ) + continue + } + if (token.type === ')') { + q.push({ index: index + 1, variant }) + continue + } + + if (token.type === ':') { + variant.key += '{:}' + variant.paramIndices.push(token.nameIndex) + q.push({ index: index + 1, variant }) + continue + } + + if (token.type === '*') { + variant.key += '{*}' + variant.paramIndices.push(token.nameIndex) + q.push({ index: index + 1, variant }) + continue + } + + if (token.type === 'text') { + variant.key += token.text + q.push({ index: index + 1, variant }) + continue + } + + throw new Error(`internal: unrecognized token type '${token.type}'`) + } + + return result +} 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..cab204b9bd0 --- /dev/null +++ b/packages/route-pattern/src/lib2/route-pattern/ast.ts @@ -0,0 +1,10 @@ +import type * as PartPattern from '../part-pattern/index.ts' +import type * as Search from './search.ts' + +export type AST = { + protocol: PartPattern.AST | undefined + hostname: PartPattern.AST | undefined + port: string | undefined + pathname: PartPattern.AST | 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 new file mode 100644 index 00000000000..4f9d8de8f10 --- /dev/null +++ b/packages/route-pattern/src/lib2/route-pattern/index.ts @@ -0,0 +1,6 @@ +export type { AST } from './ast.ts' +export { join } from './join.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/join.test.ts b/packages/route-pattern/src/lib2/route-pattern/join.test.ts new file mode 100644 index 00000000000..cc309b5b9a6 --- /dev/null +++ b/packages/route-pattern/src/lib2/route-pattern/join.test.ts @@ -0,0 +1,267 @@ +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).toStrictEqual( + new Map([ + ['a', new Set(['1'])], + ['b', new Set(['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..1abdadf672d --- /dev/null +++ b/packages/route-pattern/src/lib2/route-pattern/join.ts @@ -0,0 +1,67 @@ +import type { AST } from './ast.ts' +import type * as PartPattern from '../part-pattern/index.ts' +import * as Search from './search.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: Search.join(a.search, b.search), + } +} + +function joinPathname( + a: PartPattern.AST | undefined, + b: PartPattern.AST | undefined, +): PartPattern.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 === ':') { + tokens.push({ + ...token, + nameIndex: token.nameIndex + a.paramNames.length, + }) + return + } + if (token.type === '*') { + tokens.push({ + ...token, + nameIndex: token.nameIndex + a.paramNames.length, + }) + 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, + } +} diff --git a/packages/route-pattern/src/lib2/route-pattern/parse.test.ts b/packages/route-pattern/src/lib2/route-pattern/parse.test.ts new file mode 100644 index 00000000000..e7289950328 --- /dev/null +++ b/packages/route-pattern/src/lib2/route-pattern/parse.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' + +import { parse } from './parse.ts' + +describe('parse', () => { + it('parses a simple pathname', () => { + let ast = parse('users/:id') + expect(ast).toEqual({ + protocol: undefined, + hostname: undefined, + port: undefined, + pathname: { + tokens: [ + { type: 'text', text: 'users/' }, + { type: ':', nameIndex: 0 }, + ], + paramNames: ['id'], + optionals: new Map(), + }, + search: new Map(), + }) + }) + + it('parses a full URL pattern', () => { + let ast = parse('https://example.com/users/:id') + expect(ast).toEqual({ + 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: new Map(), + }) + }) + + it('parses protocol and pathname without hostname', () => { + let ast = parse('file:///path/to/file') + expect(ast).toEqual({ + 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: new Map(), + }) + }) +}) diff --git a/packages/route-pattern/src/lib2/route-pattern/parse.ts b/packages/route-pattern/src/lib2/route-pattern/parse.ts new file mode 100644 index 00000000000..24b3503d6e0 --- /dev/null +++ b/packages/route-pattern/src/lib2/route-pattern/parse.ts @@ -0,0 +1,34 @@ +import type { AST } from './ast.ts' +import { split } from './split.ts' +import * as PartPattern from '../part-pattern/index.ts' +import * as Search from './search.ts' + +export function parse(source: string): AST { + let ast: AST = { + protocol: undefined, + hostname: undefined, + port: undefined, + pathname: undefined, + search: new Map(), + } + + let { protocol, hostname, port, pathname, search } = split(source) + + if (protocol && protocol[0] !== protocol[1]) { + ast.protocol = PartPattern.parse(source, protocol) + } + if (hostname && hostname[0] !== hostname[1]) { + 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 = PartPattern.parse(source, pathname) + } + 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.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..3744323799e --- /dev/null +++ b/packages/route-pattern/src/lib2/route-pattern/search.ts @@ -0,0 +1,123 @@ +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 +} + +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 +} + +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] +} diff --git a/packages/route-pattern/src/lib2/route-pattern/split.ts b/packages/route-pattern/src/lib2/route-pattern/split.ts new file mode 100644 index 00000000000..996502cab09 --- /dev/null +++ b/packages/route-pattern/src/lib2/route-pattern/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 } +} diff --git a/packages/route-pattern/src/lib2/route-pattern/variants.test.ts b/packages/route-pattern/src/lib2/route-pattern/variants.test.ts new file mode 100644 index 00000000000..26a200c28a1 --- /dev/null +++ b/packages/route-pattern/src/lib2/route-pattern/variants.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' + +import { parse } from './parse.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: new Set([]), + }, + { + key: [[], [], [], ['api', 'v{:}', 'run']], + paramIndices: new Set([0]), + }, + { + key: [[], [], [], ['api', 'v{:}.{:}', 'run']], + paramIndices: new Set([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'], ['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 new file mode 100644 index 00000000000..fec2ae96532 --- /dev/null +++ b/packages/route-pattern/src/lib2/route-pattern/variants.ts @@ -0,0 +1,47 @@ +import type { AST } from './ast.ts' +import * as PartPattern from '../part-pattern/index.ts' + +type Tuple4 = [protocol: T, hostname: T, port: T, pathname: T] + +type Variant = { + key: Tuple4> + paramIndices: Set +} + +export function* variants(pattern: AST): Generator { + 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) { + 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)) + } + + yield { + key: [ + protocol ? [protocol.key] : [], + hostname?.key.split('.').reverse() ?? [], + pattern.port ? [pattern.port] : [], + pathname?.key.split('/') ?? [], + ], + paramIndices: new Set(paramIndices), + } + } + } + } +} 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] 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: {}