From 8fa7520a9b70d9d5fedf70214b7458f41713bfdb Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Sun, 4 Jan 2026 17:39:57 -0500 Subject: [PATCH 1/5] experimental: part pattern parsing --- packages/route-pattern/package.json | 1 + .../src/experimental/errors.test.ts | 24 ++ .../route-pattern/src/experimental/errors.ts | 20 ++ .../src/experimental/part-pattern.test.ts | 255 ++++++++++++++++++ .../src/experimental/part-pattern.ts | 202 ++++++++++++++ .../route-pattern/src/experimental/span.ts | 1 + pnpm-lock.yaml | 14 + 7 files changed, 517 insertions(+) create mode 100644 packages/route-pattern/src/experimental/errors.test.ts create mode 100644 packages/route-pattern/src/experimental/errors.ts create mode 100644 packages/route-pattern/src/experimental/part-pattern.test.ts create mode 100644 packages/route-pattern/src/experimental/part-pattern.ts create mode 100644 packages/route-pattern/src/experimental/span.ts diff --git a/packages/route-pattern/package.json b/packages/route-pattern/package.json index 89c498d94db..28041eaac04 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", "vitest": "4.0.15" diff --git a/packages/route-pattern/src/experimental/errors.test.ts b/packages/route-pattern/src/experimental/errors.test.ts new file mode 100644 index 00000000000..1996ff14fdd --- /dev/null +++ b/packages/route-pattern/src/experimental/errors.test.ts @@ -0,0 +1,24 @@ +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) + expect(() => {}).toThrow() + }) + + 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/experimental/errors.ts b/packages/route-pattern/src/experimental/errors.ts new file mode 100644 index 00000000000..71c61d087a1 --- /dev/null +++ b/packages/route-pattern/src/experimental/errors.ts @@ -0,0 +1,20 @@ +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 + } +} + +export class InternalError extends Error {} diff --git a/packages/route-pattern/src/experimental/part-pattern.test.ts b/packages/route-pattern/src/experimental/part-pattern.test.ts new file mode 100644 index 00000000000..fecd3fab124 --- /dev/null +++ b/packages/route-pattern/src/experimental/part-pattern.test.ts @@ -0,0 +1,255 @@ +import * as assert from 'node:assert/strict' +import test, { describe } from 'node:test' + +import { PartPattern } from './part-pattern.ts' +import { ParseError } from './errors.ts' + +describe('PartPattern', () => { + describe('parse', () => { + type AST = ConstructorParameters[0] + function assertParse(source: string, ast: AST) { + assert.deepStrictEqual(PartPattern.parse(source), new PartPattern(ast)) + } + + function assertParseError(source: string, type: ParseError['type'], index: number) { + assert.throws(() => PartPattern.parse(source), new ParseError(type, source, index)) + } + + test('parses static text', () => { + assertParse('abc', { + tokens: [{ type: 'text', text: 'abc' }], + paramNames: [], + optionals: new Map(), + }) + }) + + test('parses a variable', () => { + assertParse(':abc', { + tokens: [{ type: ':', nameIndex: 0 }], + paramNames: ['abc'], + optionals: new Map(), + }) + assertParse(':_hello_WORLD', { + tokens: [{ type: ':', nameIndex: 0 }], + paramNames: ['_hello_WORLD'], + optionals: new Map(), + }) + assertParse(':$_hello_WORLD$123$', { + tokens: [{ type: ':', nameIndex: 0 }], + paramNames: ['$_hello_WORLD$123$'], + optionals: new Map(), + }) + }) + + test('parses a wildcard', () => { + assertParse('*', { + tokens: [{ type: '*', nameIndex: 0 }], + paramNames: ['*'], + optionals: new Map(), + }) + assertParse('*abc', { + tokens: [{ type: '*', nameIndex: 0 }], + paramNames: ['abc'], + optionals: new Map(), + }) + assertParse('*_hello_WORLD', { + tokens: [{ type: '*', nameIndex: 0 }], + paramNames: ['_hello_WORLD'], + optionals: new Map(), + }) + assertParse('*$_hello_WORLD$123$', { + tokens: [{ type: '*', nameIndex: 0 }], + paramNames: ['$_hello_WORLD$123$'], + optionals: new Map(), + }) + }) + + test('parses an optional', () => { + assertParse('aa(bb)cc', { + tokens: [ + { type: 'text', text: 'aa' }, + { type: '(' }, + { type: 'text', text: 'bb' }, + { type: ')' }, + { type: 'text', text: 'cc' }, + ], + paramNames: [], + optionals: new Map([[1, 3]]), + }) + assertParse('(aa(bb)cc)', { + tokens: [ + { type: '(' }, + { type: 'text', text: 'aa' }, + { type: '(' }, + { type: 'text', text: 'bb' }, + { type: ')' }, + { type: 'text', text: 'cc' }, + { type: ')' }, + ], + paramNames: [], + optionals: new Map([ + [0, 6], + [2, 4], + ]), + }) + }) + + test('parses combinations of text, variables, wildcards, optionals', () => { + assertParse('api/(v:major(.:minor)/)run', { + 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], + ]), + }) + + assertParse('*/node_modules/(*path/):package/dist/index.:ext', { + tokens: [ + { type: '*', nameIndex: 0 }, + { type: 'text', text: '/node_modules/' }, + { type: '(' }, + { type: '*', nameIndex: 1 }, + { type: 'text', text: '/' }, + { type: ')' }, + { type: ':', nameIndex: 2 }, + { type: 'text', text: '/dist/index.' }, + { type: ':', nameIndex: 3 }, + ], + paramNames: ['*', 'path', 'package', 'ext'], + optionals: new Map([[2, 5]]), + }) + }) + + test('parses repeated param names', () => { + assertParse(':id/:id', { + tokens: [ + { type: ':', nameIndex: 0 }, + { type: 'text', text: '/' }, + { type: ':', nameIndex: 1 }, + ], + paramNames: ['id', 'id'], + optionals: new Map(), + }) + assertParse('*id/*id', { + tokens: [ + { type: '*', nameIndex: 0 }, + { type: 'text', text: '/' }, + { type: '*', nameIndex: 1 }, + ], + paramNames: ['id', 'id'], + optionals: new Map(), + }) + assertParse('*/*', { + tokens: [ + { type: '*', nameIndex: 0 }, + { type: 'text', text: '/' }, + { type: '*', nameIndex: 1 }, + ], + paramNames: ['*', '*'], + optionals: new Map(), + }) + assertParse(':a/*a/:b/*b/:b/*a/:a', { + tokens: [ + { type: ':', nameIndex: 0 }, + { type: 'text', text: '/' }, + { type: '*', nameIndex: 1 }, + { type: 'text', text: '/' }, + { type: ':', nameIndex: 2 }, + { type: 'text', text: '/' }, + { type: '*', nameIndex: 3 }, + { type: 'text', text: '/' }, + { type: ':', nameIndex: 4 }, + { type: 'text', text: '/' }, + { type: '*', nameIndex: 5 }, + { type: 'text', text: '/' }, + { type: ':', nameIndex: 6 }, + ], + paramNames: ['a', 'a', 'b', 'b', 'b', 'a', 'a'], + optionals: new Map(), + }) + }) + + test("throws 'unmatched ('", () => { + assertParseError('(', 'unmatched (', 0) + assertParseError('(()', 'unmatched (', 0) + assertParseError('()(', 'unmatched (', 2) + }) + test("throws 'unmatched )'", () => { + assertParseError(')', 'unmatched )', 0) + assertParseError(')()', 'unmatched )', 0) + assertParseError('())', 'unmatched )', 2) + }) + test("throws 'missing variable name'", () => { + assertParseError(':', 'missing variable name', 0) + assertParseError('a:', 'missing variable name', 1) + assertParseError('(a:)', 'missing variable name', 2) + assertParseError(':(a)', 'missing variable name', 0) + assertParseError(':123', 'missing variable name', 0) + assertParseError('::', 'missing variable name', 0) + }) + test("throws 'dangling escape'", () => { + assertParseError('\\', 'dangling escape', 0) + }) + }) + + describe('variants', () => { + function assertVariants(source: string, variants: ReturnType) { + assert.deepStrictEqual(PartPattern.parse(source).variants(), variants) + } + + test('produces all possible combinations of optionals', () => { + assertVariants('a(:b)*c', [ + { key: 'a{*}', paramIndices: [1] }, + { key: 'a{:}{*}', paramIndices: [0, 1] }, + ]) + assertVariants('a(:b)*c', [ + { key: 'a{*}', paramIndices: [1] }, + { key: 'a{:}{*}', paramIndices: [0, 1] }, + ]) + assertVariants('a(:b)c(*d)e', [ + { key: 'ace', paramIndices: [] }, + { key: 'ac{*}e', paramIndices: [1] }, + { key: 'a{:}ce', paramIndices: [0] }, + { key: 'a{:}c{*}e', paramIndices: [0, 1] }, + ]) + assertVariants('a(:b(*c):d)e', [ + { key: 'ae', paramIndices: [] }, + { key: 'a{:}{:}e', paramIndices: [0, 2] }, + { key: 'a{:}{*}{:}e', paramIndices: [0, 1, 2] }, + ]) + assertVariants('a(:b(*c):d)e(*f)g', [ + { key: 'aeg', paramIndices: [] }, + { key: 'ae{*}g', paramIndices: [3] }, + { key: 'a{:}{:}eg', paramIndices: [0, 2] }, + { key: 'a{:}{:}e{*}g', paramIndices: [0, 2, 3] }, + { key: 'a{:}{*}{:}eg', paramIndices: [0, 1, 2] }, + { key: 'a{:}{*}{:}e{*}g', paramIndices: [0, 1, 2, 3] }, + ]) + }) + }) + + describe('toString', () => { + test('stringifies combinations of text, variables, wildcards, optionals', () => { + let examples = [ + 'api/(v:major(.:minor)/)run', + '*/node_modules/(*path/):package/dist/index.:ext', + ] + for (let source of examples) { + assert.equal(PartPattern.parse(source).toString(), source) + } + }) + }) +}) diff --git a/packages/route-pattern/src/experimental/part-pattern.ts b/packages/route-pattern/src/experimental/part-pattern.ts new file mode 100644 index 00000000000..fa350126e34 --- /dev/null +++ b/packages/route-pattern/src/experimental/part-pattern.ts @@ -0,0 +1,202 @@ +import { ParseError } from './errors.ts' +import type { Span } from './span.ts' + +type AST = { + tokens: Array + paramNames: Array + optionals: Map +} + +type Token = + | { type: 'text'; text: string } + | { type: '(' | ')' } + | { type: ':' | '*'; nameIndex: number } + +type Variant = { + key: string + paramIndices: Array +} + +const IDENTIFIER_RE = /^[a-zA-Z_$][a-zA-Z_$0-9]*/ + +export class PartPattern { + readonly tokens: AST['tokens'] + readonly paramNames: AST['paramNames'] + readonly optionals: AST['optionals'] + + constructor(ast: AST) { + this.tokens = ast.tokens + this.paramNames = ast.paramNames + this.optionals = ast.optionals + } + + static parse(source: string, span?: Span): PartPattern { + 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 = IDENTIFIER_RE.exec(source.slice(i, span[1]))?.[0] + if (!name) { + throw new ParseError('missing variable name', source, i - 1) + } + ast.tokens.push({ type: ':', nameIndex: ast.paramNames.length }) + ast.paramNames.push(name) + i += name.length + continue + } + + // wildcard + if (char === '*') { + i += 1 + let name = IDENTIFIER_RE.exec(source.slice(i, span[1]))?.[0] + ast.tokens.push({ type: '*', nameIndex: ast.paramNames.length }) + ast.paramNames.push(name ?? '*') + i += name?.length ?? 0 + 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 new PartPattern(ast) + } + + variants(): Array { + let result: Array = [] + + let stack: Array<{ index: number; variant: Variant }> = [ + { index: 0, variant: { key: '', paramIndices: [] } }, + ] + while (stack.length > 0) { + let { index, variant } = stack.pop()! + + if (index === this.tokens.length) { + result.push(variant) + continue + } + + let token = this.tokens[index] + if (token.type === '(') { + stack.push( + { index: index + 1, variant }, // include optional + { index: this.optionals.get(index)! + 1, variant: structuredClone(variant) }, // exclude optional + ) + continue + } + if (token.type === ')') { + stack.push({ index: index + 1, variant }) + continue + } + + if (token.type === ':') { + variant.key += '{:}' + variant.paramIndices.push(token.nameIndex) + stack.push({ index: index + 1, variant }) + continue + } + + if (token.type === '*') { + variant.key += '{*}' + variant.paramIndices.push(token.nameIndex) + stack.push({ index: index + 1, variant }) + continue + } + + if (token.type === 'text') { + variant.key += token.text + stack.push({ index: index + 1, variant }) + continue + } + + throw unrecognized(token.type) + } + + return result + } + + toString(): string { + let result = '' + for (let token of this.tokens) { + if (token.type === '(' || token.type === ')') { + result += token.type + continue + } + + if (token.type === ':' || token.type === '*') { + let name = this.paramNames[token.nameIndex] + if (name === '*') name = '' + result += token.type + name + continue + } + + if (token.type === 'text') { + result += token.text + continue + } + + throw unrecognized(token.type) + } + return result + } +} + +function unrecognized(tokenType: never) { + return new Error(`Unrecognized token type '${tokenType}'`) +} diff --git a/packages/route-pattern/src/experimental/span.ts b/packages/route-pattern/src/experimental/span.ts new file mode 100644 index 00000000000..0148e1651f8 --- /dev/null +++ b/packages/route-pattern/src/experimental/span.ts @@ -0,0 +1 @@ +export type Span = [begin: number, end: number] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d50d484647a..4c3f19a5cad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -930,6 +930,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 @@ -2804,6 +2807,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'} @@ -4560,6 +4571,7 @@ packages: wrangler@4.55.0: resolution: {integrity: sha512-50icmLX8UbNaq0FmFHbcvvOh7I6rDA/FyaMYRcNSl1iX0JwuKswezmmtYvYPxPTkbYz7FUYR8GPZLaT23uzFqw==} engines: {node: '>=20.0.0'} + deprecated: This version can incorrectly automatically delegate 'wrangler deploy' to 'opennextjs-cloudflare' hasBin: true peerDependencies: '@cloudflare/workers-types': ^4.20251213.0 @@ -6250,6 +6262,8 @@ snapshots: dependencies: ms: 2.1.3 + dedent@1.7.1: {} + deep-eql@5.0.2: {} deep-extend@0.6.0: {} From f779b9639afe2692d486f26b300b62b6122c2e3d Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 5 Jan 2026 13:57:15 -0500 Subject: [PATCH 2/5] experimental: route pattern split --- .../experimental/route-pattern/split.test.ts | 85 +++++++++++++++++ .../src/experimental/route-pattern/split.ts | 95 +++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 packages/route-pattern/src/experimental/route-pattern/split.test.ts create mode 100644 packages/route-pattern/src/experimental/route-pattern/split.ts diff --git a/packages/route-pattern/src/experimental/route-pattern/split.test.ts b/packages/route-pattern/src/experimental/route-pattern/split.test.ts new file mode 100644 index 00000000000..e09b030baf3 --- /dev/null +++ b/packages/route-pattern/src/experimental/route-pattern/split.test.ts @@ -0,0 +1,85 @@ +import * as assert from 'node:assert/strict' +import test, { describe } from 'node:test' +import { split, type SplitResult } from './split.ts' + +function assertSplit(source: string, expected: Partial) { + expected.protocol = expected.protocol ?? null + expected.hostname = expected.hostname ?? null + expected.port = expected.port ?? null + expected.pathname = expected.pathname ?? null + expected.search = expected.search ?? null + + assert.deepStrictEqual(split(source), expected) +} + +describe('split', () => { + test('protocol', () => { + assertSplit('http://', { protocol: [0, 4] }) + }) + + test('hostname', () => { + assertSplit('://example.com', { hostname: [3, 14] }) + }) + + test('port', () => { + assertSplit('://example.com:8000', { hostname: [3, 14], port: [15, 19] }) + }) + + test('pathname', () => { + assertSplit('pathname', { pathname: [0, 8] }) + assertSplit('/pathname', { pathname: [1, 9] }) + assertSplit('//pathname', { pathname: [1, 10] }) + }) + + test('empty pathname', () => { + assertSplit('/', { pathname: null }) + assertSplit('http:///', { protocol: [0, 4], pathname: null }) + assertSplit('://example/', { hostname: [3, 10], pathname: null }) + }) + + test('search', () => { + assertSplit('?q=1', { search: [1, 4] }) + }) + + test('protocol + hostname', () => { + assertSplit('http://example.com', { protocol: [0, 4], hostname: [7, 18] }) + }) + + test('protocol + pathname', () => { + assertSplit('http:///pathname', { protocol: [0, 4], pathname: [8, 16] }) + }) + + test('hostname + pathname', () => { + assertSplit('://example.com/pathname', { hostname: [3, 14], pathname: [15, 23] }) + }) + + test('protocol + hostname + pathname', () => { + assertSplit('http://example.com/pathname', { + protocol: [0, 4], + hostname: [7, 18], + pathname: [19, 27], + }) + }) + + test('protocol + hostname + port + pathname + search', () => { + assertSplit('http://example.com:8000/pathname?q=1', { + protocol: [0, 4], + hostname: [7, 18], + port: [19, 23], + pathname: [24, 32], + search: [33, 36], + }) + }) + + test('/ before ://', () => { + assertSplit('pathname/then://solidus', { pathname: [0, 23] }) + assertSplit('/pathname/then://solidus', { pathname: [1, 24] }) + }) + + test('? before ://', () => { + assertSplit('?search://solidus', { search: [1, 17] }) + }) + test('? before /', () => { + assertSplit('?search/slash', { search: [1, 13] }) + }) +}) diff --git a/packages/route-pattern/src/experimental/route-pattern/split.ts b/packages/route-pattern/src/experimental/route-pattern/split.ts new file mode 100644 index 00000000000..4ed8bb06372 --- /dev/null +++ b/packages/route-pattern/src/experimental/route-pattern/split.ts @@ -0,0 +1,95 @@ +import type { Span } from '../span' + +export type SplitResult = { + protocol: Span | null + hostname: Span | null + port: Span | null + pathname: Span | null + search: Span | null +} + +/** + * Split a route pattern into protocol, hostname, port, pathname, and search + * spans delimited as `protocol://hostname:port/pathname?search`. + * + * Delimiters are not included in the spans with the exception of the leading `/` for pathname. + * Spans are [begin (inclusive), end (exclusive)]. + */ +export function split(source: string): SplitResult { + let result: SplitResult = { + protocol: null, + hostname: null, + port: null, + pathname: null, + search: null, + } + + let questionMarkIndex = source.indexOf('?') + if (questionMarkIndex !== -1) { + result.search = span(questionMarkIndex + 1, source.length) + source = source.slice(0, questionMarkIndex) + } + + let solidusIndex = source.indexOf('://') + + if (solidusIndex === -1) { + // path/without/solidus + result.pathname = pathnameSpan(source, 0, source.length) + return result + } + + let slashIndex = source.indexOf('/') + if (slashIndex === solidusIndex + 1) { + // first slash is from solidus, find next slash + slashIndex = source.indexOf('/', solidusIndex + 3) + } + + if (slashIndex === -1) { + // (protocol)://(host) + result.protocol = span(0, solidusIndex) + const host = span(solidusIndex + 3, source.length) + if (host) { + const { hostname, port } = hostSpans(source, host) + result.hostname = hostname + result.port = port + } + return result + } + + if (slashIndex < solidusIndex) { + // pathname/with://solidus + result.pathname = pathnameSpan(source, 0, source.length) + return result + } + + // (protocol)://(host)/(pathname) + result.protocol = span(0, solidusIndex) + const host = span(solidusIndex + 3, slashIndex) + if (host) { + const { hostname, port } = hostSpans(source, host) + result.hostname = hostname + result.port = port + } + result.pathname = pathnameSpan(source, slashIndex, source.length) + return result +} + +function span(start: number, end: number): Span | null { + if (start === end) return null + return [start, end] +} + +function hostSpans(source: string, host: Span): { hostname: Span | null; port: Span | null } { + let lastColonIndex = source.slice(0, host[1]).lastIndexOf(':') + if (lastColonIndex === -1 || lastColonIndex < host[0]) return { hostname: host, port: null } + + if (source.slice(lastColonIndex + 1, host[1]).match(/^\d+$/)) { + return { hostname: span(host[0], lastColonIndex), port: span(lastColonIndex + 1, host[1]) } + } + return { hostname: host, port: null } +} + +function pathnameSpan(source: string, begin: number, end: number): Span | null { + if (source[begin] === '/') begin += 1 + return span(begin, end) +} From 03e016a8c9ab6c8f5dd6e733eb2284ace6423738 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 7 Jan 2026 14:45:15 -0500 Subject: [PATCH 3/5] RoutePattern.parse --- .../src/experimental/route-pattern/ast.ts | 21 +++ .../src/experimental/route-pattern/index.ts | 1 + .../src/experimental/route-pattern/parse.ts | 36 +++++ .../route-pattern/route-pattern.test.ts | 130 ++++++++++++++++++ .../route-pattern/route-pattern.ts | 30 ++++ 5 files changed, 218 insertions(+) create mode 100644 packages/route-pattern/src/experimental/route-pattern/ast.ts create mode 100644 packages/route-pattern/src/experimental/route-pattern/index.ts create mode 100644 packages/route-pattern/src/experimental/route-pattern/parse.ts create mode 100644 packages/route-pattern/src/experimental/route-pattern/route-pattern.test.ts create mode 100644 packages/route-pattern/src/experimental/route-pattern/route-pattern.ts diff --git a/packages/route-pattern/src/experimental/route-pattern/ast.ts b/packages/route-pattern/src/experimental/route-pattern/ast.ts new file mode 100644 index 00000000000..7a2f94f92cc --- /dev/null +++ b/packages/route-pattern/src/experimental/route-pattern/ast.ts @@ -0,0 +1,21 @@ +import type { PartPattern } from '../part-pattern' + +export type AST = { + protocol: PartPattern + hostname: PartPattern + port: string | null + pathname: PartPattern + + /** + * - `null`: key must be present + * - Empty `Set`: key must be present with a value + * - Non-empty `Set`: key must be present with all these values + * + * ```ts + * new Map([['q', null]]) // -> ?q, ?q=, ?q=1 + * new Map([['q', new Set()]]) // -> ?q=1 + * new Map([['q', new Set(['x', 'y'])]]) // -> ?q=x&q=y + * ``` + */ + search: Map | null> +} diff --git a/packages/route-pattern/src/experimental/route-pattern/index.ts b/packages/route-pattern/src/experimental/route-pattern/index.ts new file mode 100644 index 00000000000..4fecd83d05e --- /dev/null +++ b/packages/route-pattern/src/experimental/route-pattern/index.ts @@ -0,0 +1 @@ +export { RoutePattern } from './route-pattern.ts' diff --git a/packages/route-pattern/src/experimental/route-pattern/parse.ts b/packages/route-pattern/src/experimental/route-pattern/parse.ts new file mode 100644 index 00000000000..d9678ff02a1 --- /dev/null +++ b/packages/route-pattern/src/experimental/route-pattern/parse.ts @@ -0,0 +1,36 @@ +import type { AST } from './ast.ts' + +export function search(source: string): AST['search'] { + let constraints: AST['search'] = new Map() + + for (let param of source.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 +} diff --git a/packages/route-pattern/src/experimental/route-pattern/route-pattern.test.ts b/packages/route-pattern/src/experimental/route-pattern/route-pattern.test.ts new file mode 100644 index 00000000000..088199e4287 --- /dev/null +++ b/packages/route-pattern/src/experimental/route-pattern/route-pattern.test.ts @@ -0,0 +1,130 @@ +import * as assert from 'node:assert/strict' +import test, { describe } from 'node:test' +import { RoutePattern } from './route-pattern.ts' +import type { AST } from './ast.ts' + +describe('RoutePattern', () => { + describe('parse', () => { + function assertParse( + source: string, + expected: { [K in Exclude]?: string } & { + search?: Record | null> + }, + ) { + let pattern = RoutePattern.parse(source) + let expectedSearch = new Map() + if (expected.search) { + for (let name in expected.search) { + let value = expected.search[name] + expectedSearch.set(name, value ? new Set(expected.search[name]) : null) + } + } + assert.deepStrictEqual( + { + protocol: pattern.ast.protocol?.toString(), + hostname: pattern.ast.hostname?.toString(), + port: pattern.ast.port ?? null, + pathname: pattern.ast.pathname?.toString(), + search: pattern.ast.search, + }, + { + // explicitly set each prop so that we can omitted keys from `expected` to set them as defaults + protocol: expected.protocol ?? '*', + hostname: expected.hostname ?? '*', + port: expected.port ?? null, + pathname: expected.pathname ?? '', + search: expectedSearch, + }, + ) + } + + test('parses hostname', () => { + assertParse('://example.com', { hostname: 'example.com' }) + }) + + test('parses port', () => { + assertParse('://example.com:8000', { hostname: 'example.com', port: '8000' }) + }) + + test('parses pathname', () => { + assertParse('products/:id', { pathname: 'products/:id' }) + }) + + test('parses search', () => { + assertParse('?q', { search: { q: null } }) + assertParse('?q=', { search: { q: [] } }) + assertParse('?q=1', { search: { q: ['1'] } }) + }) + + test('parses protocol + hostname', () => { + assertParse('https://example.com', { + protocol: 'https', + hostname: 'example.com', + }) + }) + + test('parses protocol + pathname', () => { + assertParse('http:///dir/file', { + protocol: 'http', + pathname: 'dir/file', + }) + }) + + test('parses hostname + pathname', () => { + assertParse('://example.com/about', { + hostname: 'example.com', + pathname: 'about', + }) + }) + + test('parses protocol + hostname + pathname', () => { + assertParse('https://example.com/about', { + protocol: 'https', + hostname: 'example.com', + pathname: 'about', + }) + }) + + test('parses protocol + hostname + search', () => { + assertParse('https://example.com?q=1', { + protocol: 'https', + hostname: 'example.com', + search: { q: ['1'] }, + }) + }) + + test('parses protocol + pathname + search', () => { + assertParse('http:///dir/file?q=1', { + protocol: 'http', + pathname: 'dir/file', + search: { q: ['1'] }, + }) + }) + + test('parses hostname + pathname + search', () => { + assertParse('://example.com/about?q=1', { + hostname: 'example.com', + pathname: 'about', + search: { q: ['1'] }, + }) + }) + + test('parses protocol + hostname + pathname + search', () => { + assertParse('https://example.com/about?q=1', { + protocol: 'https', + hostname: 'example.com', + pathname: 'about', + search: { q: ['1'] }, + }) + }) + + test('parses search params into constraints grouped by param name', () => { + assertParse('?q&q', { search: { q: null } }) + assertParse('?q&q=', { search: { q: [] } }) + assertParse('?q&q=1', { search: { q: ['1'] } }) + assertParse('?q=&q=1', { search: { q: ['1'] } }) + assertParse('?q=1&q=2', { search: { q: ['1', '2'] } }) + assertParse('?q&q=&q=1&q=2', { search: { q: ['1', '2'] } }) + }) + }) +}) diff --git a/packages/route-pattern/src/experimental/route-pattern/route-pattern.ts b/packages/route-pattern/src/experimental/route-pattern/route-pattern.ts new file mode 100644 index 00000000000..2b1571ea0ef --- /dev/null +++ b/packages/route-pattern/src/experimental/route-pattern/route-pattern.ts @@ -0,0 +1,30 @@ +import type { AST } from './ast.ts' +import { split } from './split.ts' +import * as Parse from './parse.ts' +import { PartPattern } from '../part-pattern.ts' + +export class RoutePattern { + readonly ast: AST + + private constructor(ast: AST) { + this.ast = ast + } + + static parse(source: string): RoutePattern { + let spans = split(source) + + return new RoutePattern({ + protocol: spans.protocol + ? PartPattern.parse(source, spans.protocol) + : PartPattern.parse('*', [0, 1]), + hostname: spans.hostname + ? PartPattern.parse(source, spans.hostname) + : PartPattern.parse('*', [0, 1]), + port: spans.port ? source.slice(...spans.port) : null, + pathname: spans.pathname + ? PartPattern.parse(source, spans.pathname) + : PartPattern.parse('', [0, 0]), + search: spans.search ? Parse.search(source.slice(...spans.search)) : new Map(), + }) + } +} From 676435332b8a3fde122d8a318fe5838b4790836e Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 7 Jan 2026 15:50:44 -0500 Subject: [PATCH 4/5] RoutePattern.join --- .../src/experimental/route-pattern/join.ts | 116 ++++++++++++++++++ .../route-pattern/route-pattern.test.ts | 110 +++++++++++++++++ .../route-pattern/route-pattern.ts | 19 +++ 3 files changed, 245 insertions(+) create mode 100644 packages/route-pattern/src/experimental/route-pattern/join.ts diff --git a/packages/route-pattern/src/experimental/route-pattern/join.ts b/packages/route-pattern/src/experimental/route-pattern/join.ts new file mode 100644 index 00000000000..662130ec0f1 --- /dev/null +++ b/packages/route-pattern/src/experimental/route-pattern/join.ts @@ -0,0 +1,116 @@ +import { PartPattern } from '../part-pattern.ts' +import type { AST } from './ast.ts' + +/** + * Joins two pathnames, adding `/` at the join point unless already present. + * + * Conceptually: + * + * ```ts + * pathname('a', 'b') -> 'a/b' + * pathname('a/', 'b') -> 'a/b' + * pathname('a', '/b') -> 'a/b' + * pathname('a/', '/b') -> 'a/b' + * pathname('(a/)', '(/b)') -> '(a/)/(/b)' + * ``` + */ +export function pathname(a: PartPattern, b: PartPattern): PartPattern { + if (a.tokens.length === 0) return b + if (b.tokens.length === 0) return a + + let aLast = a.tokens.at(-1) + let bFirst = b.tokens[0] + + let tokens = a.tokens.slice(0, -1) + let tokenOffset = tokens.length + + if (aLast?.type === 'text' && bFirst?.type === 'text') { + // Note: leading `/` is ignored when parsing pathnames so `/b` is the same as `b` + // so no need to explicitly dedup `/` for `.join('a/', '/b')` as its the same as `.join('a/', 'b')` + let needsSlash = !aLast.text.endsWith('/') && !bFirst.text.startsWith('/') + tokens.push({ type: 'text', text: aLast.text + (needsSlash ? '/' : '') + bFirst.text }) + tokenOffset += 1 + } else if (aLast?.type === 'text') { + let needsSlash = !aLast.text.endsWith('/') + tokens.push({ type: 'text', text: needsSlash ? aLast.text + '/' : aLast.text }) + tokenOffset += 1 + if (bFirst) { + tokens.push(bFirst) + tokenOffset += 1 + } + } else if (bFirst?.type === 'text') { + if (aLast) { + tokens.push(aLast) + tokenOffset += 1 + } + let needsSlash = !bFirst.text.startsWith('/') + tokens.push({ type: 'text', text: (needsSlash ? '/' : '') + bFirst.text }) + tokenOffset += 1 + } else { + if (aLast) { + tokens.push(aLast) + tokenOffset += 1 + } + tokens.push({ type: 'text', text: '/' }) + tokenOffset += 1 + if (bFirst) { + tokens.push(bFirst) + tokenOffset += 1 + } + } + + for (let i = 1; i < b.tokens.length; i++) { + let token = b.tokens[i] + if (token.type === ':' || token.type === '*') { + tokens.push({ ...token, nameIndex: token.nameIndex + a.paramNames.length }) + } else { + tokens.push(token) + } + } + + let paramNames = [...a.paramNames, ...b.paramNames] + + let optionals = new Map(a.optionals) + for (let [begin, end] of b.optionals) { + optionals.set(tokenOffset + begin - 1, tokenOffset + end - 1) + } + + return new PartPattern({ tokens, paramNames, optionals }) +} + +/** + * Joins two search patterns, merging params and their constraints. + * + * Conceptually: + * + * ```ts + * search('?a', '?b') -> '?a&b' + * search('?a=1', '?a=2') -> '?a=1&a=2' + * search('?a=1', '?b=2') -> '?a=1&b=2' + * search('', '?a') -> '?a' + * ``` + */ +export function search(a: AST['search'], b: AST['search']): AST['search'] { + let result: AST['search'] = 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 + } + + if (constraint !== null) { + for (let value of constraint) { + current.add(value) + } + } + } + + return result +} diff --git a/packages/route-pattern/src/experimental/route-pattern/route-pattern.test.ts b/packages/route-pattern/src/experimental/route-pattern/route-pattern.test.ts index 088199e4287..c269b1c7e37 100644 --- a/packages/route-pattern/src/experimental/route-pattern/route-pattern.test.ts +++ b/packages/route-pattern/src/experimental/route-pattern/route-pattern.test.ts @@ -127,4 +127,114 @@ describe('RoutePattern', () => { assertParse('?q&q=&q=1&q=2', { search: { q: ['1', '2'] } }) }) }) + + describe('join', () => { + function assertJoin(a: string, b: string, expected: string) { + assert.deepStrictEqual( + RoutePattern.parse(a).join(RoutePattern.parse(b)), + RoutePattern.parse(expected), + ) + } + + test('protocol', () => { + assertJoin('http://', '*://', 'http://') + assertJoin('*://', '*://', '*://') + assertJoin('*://', 'http://', 'http://') + + assertJoin('http://', '*proto://', '*proto://') + assertJoin('*proto://', 'http://', 'http://') + assertJoin('*proto://', '*other://', '*other://') + assertJoin('*://', '*proto://', '*proto://') + + assertJoin('http://', 'https://', 'https://') + assertJoin('://example.com', 'https://', 'https://example.com') + assertJoin('http://example.com', 'https://', 'https://example.com') + }) + + test('hostname', () => { + assertJoin('://example.com', '://*', '://example.com') + assertJoin('://*', '://*', '://*') + assertJoin('://*', '://example.com', '://example.com') + + assertJoin('://example.com', '://*host', '://*host') + assertJoin('://*host', '://example.com', '://example.com') + assertJoin('://*host', '://*other', '://*other') + assertJoin('://*', '://*host', '://*host') + + assertJoin('://example.com', '://other.com', '://other.com') + assertJoin('://', '://other.com', '://other.com') + assertJoin('http://example.com', '://other.com', 'http://other.com') + assertJoin('://example.com/pathname', '://other.com', '://other.com/pathname') + assertJoin('/pathname', '://other.com', '://other.com/pathname') + }) + + test('port', () => { + assertJoin('://:8000', '://', '://:8000') + assertJoin('://', '://:8000', '://:8000') + assertJoin('://:8000', '://:3000', '://:3000') + assertJoin('://example.com', '://example.com:8000', '://example.com:8000') + assertJoin('http://example.com:4321', '://example.com:8000', 'http://example.com:8000') + }) + + test('pathname', () => { + assertJoin('', '', '') + assertJoin('', 'b', 'b') + assertJoin('a', '', 'a') + + assertJoin('a', 'b', 'a/b') + assertJoin('a/', 'b', 'a/b') + assertJoin('a', '/b', 'a/b') + assertJoin('a/', '/b', 'a/b') + + assertJoin('(a/)', 'b', '(a/)/b') + assertJoin('(a/)', '/b', '(a/)/b') + assertJoin('a', '(/b)', 'a/(/b)') + assertJoin('a/', '(/b)', 'a/(/b)') + + assertJoin('(a/)', '(/b)', '(a/)/(/b)') + assertJoin('((a/))', '((/b))', '((a/))/((/b))') + }) + + test('search', () => { + assertJoin('path', '?a', 'path?a') + assertJoin('?a', '?b=1', '?a&b=1') + assertJoin('?a=1', '?b=2', '?a=1&b=2') + }) + + test('combos', () => { + assertJoin('http://example.com/a', '*proto://*host/b', '*proto://*host/a/b') + assertJoin('http://example.com:8000/a', 'https:///b', 'https://example.com:8000/a/b') + assertJoin('http://example.com:8000/a', '://other.com/b', 'http://other.com:8000/a/b') + + assertJoin( + 'https://api.example.com:8000/v1/:resource', + '/users/(admin/)posts?filter&sort=asc', + 'https://api.example.com:8000/v1/:resource/users/(admin/)posts?filter&sort=asc', + ) + + assertJoin( + '*proto://example.com/base', + '*proto://other.com/path', + '*proto://other.com/base/path', + ) + + assertJoin( + 'http://old.com:3000/keep/this', + 'https://new.com:8080', + 'https://new.com:8080/keep/this', + ) + + assertJoin( + 'users/:id?tab=profile', + 'posts/:postId?sort=recent', + 'users/:id/posts/:postId?tab=profile&sort=recent', + ) + + assertJoin( + '://(staging.)example.com/api(/:version)', + '://*/resources/:id(.json)', + '://(staging.)example.com/api(/:version)/resources/:id(.json)', + ) + }) + }) }) diff --git a/packages/route-pattern/src/experimental/route-pattern/route-pattern.ts b/packages/route-pattern/src/experimental/route-pattern/route-pattern.ts index 2b1571ea0ef..66f55946a79 100644 --- a/packages/route-pattern/src/experimental/route-pattern/route-pattern.ts +++ b/packages/route-pattern/src/experimental/route-pattern/route-pattern.ts @@ -1,5 +1,6 @@ import type { AST } from './ast.ts' import { split } from './split.ts' +import * as Join from './join.ts' import * as Parse from './parse.ts' import { PartPattern } from '../part-pattern.ts' @@ -27,4 +28,22 @@ export class RoutePattern { search: spans.search ? Parse.search(source.slice(...spans.search)) : new Map(), }) } + + join(other: RoutePattern): RoutePattern { + return new RoutePattern({ + protocol: isNamelessWildcard(other.ast.protocol) ? this.ast.protocol : other.ast.protocol, + hostname: isNamelessWildcard(other.ast.hostname) ? this.ast.hostname : other.ast.hostname, + port: other.ast.port ?? this.ast.port, + pathname: Join.pathname(this.ast.pathname, other.ast.pathname), + search: Join.search(this.ast.search, other.ast.search), + }) + } +} + +function isNamelessWildcard(part: PartPattern): boolean { + if (part.tokens.length !== 1) return false + let token = part.tokens[0] + if (token.type !== '*') return false + const name = part.paramNames[token.nameIndex] + return name === '*' } From 7db81db22dfabd98b61a0b504a9beed99868ae7e Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 8 Jan 2026 11:55:39 -0500 Subject: [PATCH 5/5] PartPattern.variants - track param names instead of param indices - cache in `#variants` - `get` for `.variants` instead of `.variants()` --- .../src/experimental/part-pattern.test.ts | 38 +++++++++---------- .../src/experimental/part-pattern.ts | 16 +++++--- 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/packages/route-pattern/src/experimental/part-pattern.test.ts b/packages/route-pattern/src/experimental/part-pattern.test.ts index fecd3fab124..e3f9c59fbdc 100644 --- a/packages/route-pattern/src/experimental/part-pattern.test.ts +++ b/packages/route-pattern/src/experimental/part-pattern.test.ts @@ -206,37 +206,37 @@ describe('PartPattern', () => { }) describe('variants', () => { - function assertVariants(source: string, variants: ReturnType) { - assert.deepStrictEqual(PartPattern.parse(source).variants(), variants) + function assertVariants(source: string, variants: PartPattern['variants']) { + assert.deepStrictEqual(PartPattern.parse(source).variants, variants) } test('produces all possible combinations of optionals', () => { assertVariants('a(:b)*c', [ - { key: 'a{*}', paramIndices: [1] }, - { key: 'a{:}{*}', paramIndices: [0, 1] }, + { key: 'a{*}', paramNames: ['c'] }, + { key: 'a{:}{*}', paramNames: ['b', 'c'] }, ]) assertVariants('a(:b)*c', [ - { key: 'a{*}', paramIndices: [1] }, - { key: 'a{:}{*}', paramIndices: [0, 1] }, + { key: 'a{*}', paramNames: ['c'] }, + { key: 'a{:}{*}', paramNames: ['b', 'c'] }, ]) assertVariants('a(:b)c(*d)e', [ - { key: 'ace', paramIndices: [] }, - { key: 'ac{*}e', paramIndices: [1] }, - { key: 'a{:}ce', paramIndices: [0] }, - { key: 'a{:}c{*}e', paramIndices: [0, 1] }, + { key: 'ace', paramNames: [] }, + { key: 'ac{*}e', paramNames: ['d'] }, + { key: 'a{:}ce', paramNames: ['b'] }, + { key: 'a{:}c{*}e', paramNames: ['b', 'd'] }, ]) assertVariants('a(:b(*c):d)e', [ - { key: 'ae', paramIndices: [] }, - { key: 'a{:}{:}e', paramIndices: [0, 2] }, - { key: 'a{:}{*}{:}e', paramIndices: [0, 1, 2] }, + { key: 'ae', paramNames: [] }, + { key: 'a{:}{:}e', paramNames: ['b', 'd'] }, + { key: 'a{:}{*}{:}e', paramNames: ['b', 'c', 'd'] }, ]) assertVariants('a(:b(*c):d)e(*f)g', [ - { key: 'aeg', paramIndices: [] }, - { key: 'ae{*}g', paramIndices: [3] }, - { key: 'a{:}{:}eg', paramIndices: [0, 2] }, - { key: 'a{:}{:}e{*}g', paramIndices: [0, 2, 3] }, - { key: 'a{:}{*}{:}eg', paramIndices: [0, 1, 2] }, - { key: 'a{:}{*}{:}e{*}g', paramIndices: [0, 1, 2, 3] }, + { key: 'aeg', paramNames: [] }, + { key: 'ae{*}g', paramNames: ['f'] }, + { key: 'a{:}{:}eg', paramNames: ['b', 'd'] }, + { key: 'a{:}{:}e{*}g', paramNames: ['b', 'd', 'f'] }, + { key: 'a{:}{*}{:}eg', paramNames: ['b', 'c', 'd'] }, + { key: 'a{:}{*}{:}e{*}g', paramNames: ['b', 'c', 'd', 'f'] }, ]) }) }) diff --git a/packages/route-pattern/src/experimental/part-pattern.ts b/packages/route-pattern/src/experimental/part-pattern.ts index fa350126e34..19f856f76b2 100644 --- a/packages/route-pattern/src/experimental/part-pattern.ts +++ b/packages/route-pattern/src/experimental/part-pattern.ts @@ -14,7 +14,7 @@ type Token = type Variant = { key: string - paramIndices: Array + paramNames: Array } const IDENTIFIER_RE = /^[a-zA-Z_$][a-zA-Z_$0-9]*/ @@ -23,6 +23,7 @@ export class PartPattern { readonly tokens: AST['tokens'] readonly paramNames: AST['paramNames'] readonly optionals: AST['optionals'] + #variants: Array | undefined constructor(ast: AST) { this.tokens = ast.tokens @@ -118,11 +119,15 @@ export class PartPattern { return new PartPattern(ast) } - variants(): Array { + get variants(): Array { + if (this.#variants !== undefined) { + return this.#variants + } + let result: Array = [] let stack: Array<{ index: number; variant: Variant }> = [ - { index: 0, variant: { key: '', paramIndices: [] } }, + { index: 0, variant: { key: '', paramNames: [] } }, ] while (stack.length > 0) { let { index, variant } = stack.pop()! @@ -147,14 +152,14 @@ export class PartPattern { if (token.type === ':') { variant.key += '{:}' - variant.paramIndices.push(token.nameIndex) + variant.paramNames.push(this.paramNames[token.nameIndex]) stack.push({ index: index + 1, variant }) continue } if (token.type === '*') { variant.key += '{*}' - variant.paramIndices.push(token.nameIndex) + variant.paramNames.push(this.paramNames[token.nameIndex]) stack.push({ index: index + 1, variant }) continue } @@ -168,6 +173,7 @@ export class PartPattern { throw unrecognized(token.type) } + this.#variants = result return result }