Skip to content

Commit 1929de9

Browse files
committed
match!
1 parent 96a02d5 commit 1929de9

File tree

8 files changed

+138
-30
lines changed

8 files changed

+138
-30
lines changed

packages/route-pattern/src/lib/ast.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
type BaseNode =
2+
| { type: 'text'; value: string }
3+
| { type: 'param'; name?: string }
4+
| { type: 'glob'; name?: string };
5+
export type Optional = { type: 'optional'; nodes: Array<BaseNode> };
6+
export type Node = BaseNode | Optional;
7+
8+
export function toRegExp(part: Array<Node>, paramRegExp?: RegExp) {
9+
const source = toRegExpSource(part, paramRegExp);
10+
return new RegExp(source);
11+
}
12+
13+
function escape(text: string): string {
14+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
15+
}
16+
17+
function toRegExpSource(part: Array<Node>, paramRegExp?: RegExp) {
18+
const source: string = part
19+
.map((node) => {
20+
if (node.type === 'param') {
21+
if (!paramRegExp) {
22+
throw new Error('Unexpected param');
23+
}
24+
let source = '(';
25+
if (node.name) {
26+
source += `?<${node.name}>`;
27+
}
28+
source += paramRegExp.source;
29+
source += ')';
30+
return source;
31+
}
32+
if (node.type === 'glob') {
33+
let source = '(';
34+
if (node.name) {
35+
source += `?<${node.name}>`;
36+
}
37+
source += '.*)';
38+
return source;
39+
}
40+
if (node.type === 'optional') {
41+
return `(?:${toRegExpSource(node.nodes, paramRegExp)})?`;
42+
}
43+
if (node.type === 'text') {
44+
return escape(node.value);
45+
}
46+
})
47+
.join('');
48+
return source;
49+
}

packages/route-pattern/src/lib/tokenize.test.ts renamed to packages/route-pattern/src/lib/lex.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as assert from 'node:assert/strict';
22
import { describe, it } from 'node:test';
33

4-
import { lexProtocol, lexHostname, lexPathname } from './tokenize.ts';
4+
import { lexProtocol, lexHostname, lexPathname } from './lex.ts';
55

66
describe('lex', () => {
77
it('lexes protocol', () => {

packages/route-pattern/src/lib/tokenize.ts renamed to packages/route-pattern/src/lib/lex.ts

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
1-
export type Token = { span: [number, number] } & (
2-
| { type: 'text'; value: string }
3-
| { type: 'param'; name: string }
4-
| { type: 'glob'; name: string }
5-
| { type: '(' | ')' }
6-
);
1+
import type { Token } from './token';
72

83
type Lexer = (source: string, index: number) => Token | null;
94

10-
function* tokenize(source: string, lexers: Array<Lexer>): Generator<Token> {
5+
function* lex(source: string, lexers: Array<Lexer>): Generator<Token> {
116
let buffer = '';
127

138
let index = 0;
@@ -36,7 +31,7 @@ function* tokenize(source: string, lexers: Array<Lexer>): Generator<Token> {
3631
}
3732
}
3833

39-
const parensLexer: Lexer = (source, index) => {
34+
const paren: Lexer = (source, index) => {
4035
const char = source[index];
4136
if (char === '(' || char === ')') {
4237
return { type: char, span: [index, 1] };
@@ -47,25 +42,21 @@ const parensLexer: Lexer = (source, index) => {
4742
const identifierRE = /[a-zA-Z_$][a-zA-Z_$0-9]*/;
4843

4944
const paramRE = new RegExp('^:(' + identifierRE.source + ')?');
50-
const paramLexer: Lexer = (source, index) => {
45+
const param: Lexer = (source, index) => {
5146
const match = paramRE.exec(source.slice(index));
5247
if (!match) return null;
5348
const name = match[1];
54-
if (name === undefined) throw new Error('todo: missing param name');
5549
return { type: 'param', name, span: [index, match[0].length] };
5650
};
5751

5852
const globRE = new RegExp('^\\*(' + identifierRE.source + ')?');
59-
const globLexer: Lexer = (source, index) => {
53+
const glob: Lexer = (source, index) => {
6054
const match = globRE.exec(source.slice(index));
6155
if (!match) return null;
6256
const name = match[1];
63-
if (name === undefined) throw new Error('todo: missing glob name');
6457
return { type: 'glob', name, span: [index, match[0].length] };
6558
};
6659

67-
export const lexProtocol = (source: string) => tokenize(source, [parensLexer]);
68-
export const lexHostname = (source: string) =>
69-
tokenize(source, [paramLexer, globLexer, parensLexer]);
70-
export const lexPathname = (source: string) =>
71-
tokenize(source, [paramLexer, globLexer, parensLexer]);
60+
export const lexProtocol = (source: string) => lex(source, [paren]);
61+
export const lexHostname = (source: string) => lex(source, [param, glob, paren]);
62+
export const lexPathname = (source: string) => lex(source, [param, glob, paren]);
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import * as assert from 'node:assert/strict';
2+
import { describe, it } from 'node:test';
3+
4+
import { createMatcher } from './match.ts';
5+
6+
describe('match', () => {
7+
it('matches', () => {
8+
const matcher = createMatcher([
9+
'products/:id',
10+
'products/sku-:sku(/compare/sku-:sku2)',
11+
'blog/:year-:month-:day/:slug(.html)',
12+
'://:tenant.remix.run/admin/users/:userId',
13+
]);
14+
15+
const url = 'https://remix.run/products/wireless-headphones';
16+
17+
assert.deepStrictEqual(matcher.match(url), [
18+
{ pattern: 'products/:id', params: { id: 'wireless-headphones' } },
19+
]);
20+
});
21+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { toRegExp } from './ast.ts';
2+
import { parseProtocol, parseHostname, parsePathname } from './parse.ts';
3+
import { split } from './split.ts';
4+
5+
export function createMatcher(patterns: Array<string>) {
6+
const blahs = patterns.map((pattern) => {
7+
const s = split(pattern);
8+
const protocol = s.protocol === undefined ? undefined : parseProtocol(s.protocol);
9+
const hostname = s.hostname === undefined ? undefined : parseHostname(s.hostname);
10+
const pathname = s.pathname === undefined ? undefined : parsePathname(s.pathname);
11+
12+
const regexs = {
13+
protocol: protocol === undefined ? /.*/ : toRegExp(protocol),
14+
hostname: hostname === undefined ? /.*/ : toRegExp(hostname, /[^.]*/),
15+
pathname: pathname === undefined ? /^\/$/ : toRegExp(pathname, /[^/]*/),
16+
};
17+
return { pattern, regexs };
18+
});
19+
20+
const match = function (url: string | URL) {
21+
const matches: Array<{ pattern: string; params: Record<string, string | undefined> }> = [];
22+
if (typeof url === 'string') {
23+
url = new URL(url);
24+
}
25+
for (const blah of blahs) {
26+
const protocol = blah.regexs.protocol.exec(url.protocol);
27+
const hostname = blah.regexs.hostname.exec(url.hostname);
28+
const pathname = blah.regexs.pathname.exec(url.pathname);
29+
if (protocol === null || hostname === null || pathname === null) {
30+
continue;
31+
}
32+
matches.push({ pattern: blah.pattern, params: { ...hostname.groups, ...pathname.groups } });
33+
}
34+
return matches;
35+
};
36+
return { match };
37+
}

packages/route-pattern/src/lib/parse.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ describe('parse', () => {
77
it('parses protocol', () => {
88
assert.deepStrictEqual(parseProtocol('http(s)'), [
99
{ span: [0, 4], type: 'text', value: 'http' },
10-
{ span: [4, 3], type: 'optional', nodes: [{ span: [5, 1], type: 'text', value: 's' }] },
10+
{ type: 'optional', nodes: [{ span: [5, 1], type: 'text', value: 's' }] },
1111
]);
1212
});
1313
});

packages/route-pattern/src/lib/parse.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,34 @@
1-
import { lexHostname, lexPathname, lexProtocol, type Token } from './tokenize.ts';
2-
3-
type Optional = { type: 'optional'; nodes: Array<Token>; span: [number, number] };
4-
type Node = Token | Optional;
1+
import type { Node, Optional } from './ast.ts';
2+
import { lexHostname, lexPathname, lexProtocol } from './lex.ts';
3+
import type { Token } from './token.ts';
54

65
function parseOptionals(tokens: Iterable<Token>) {
76
const nodes: Array<Node> = [];
87

9-
let optional: Optional | null = null;
8+
let optional: { node: Optional; index: number } | null = null;
109
for (const token of tokens) {
1110
if (token.type === '(') {
1211
if (optional) {
13-
throw new Error(`Nested paren: ${optional.span[0]} ${token.span[0]}`);
12+
throw new Error(`Nested paren at index: ${optional.index} ${token.span[0]}`);
1413
}
15-
optional = { type: 'optional', nodes: [], span: token.span };
14+
optional = { node: { type: 'optional', nodes: [] }, index: token.span[0] };
1615
continue;
1716
}
1817
if (token.type === ')') {
1918
if (!optional) {
20-
throw new Error(`Unbalanced paren: ${token.span[0]}`);
19+
throw new Error(`Unbalanced paren at index: ${token.span[0]}`);
2120
}
22-
optional.span[1] = optional.nodes.reduce((acc, node) => acc + node.span[1], 0) + 2;
23-
nodes.push(optional);
21+
nodes.push(optional.node);
2422
optional = null;
2523
continue;
2624
}
27-
(optional?.nodes ?? nodes).push(token);
25+
26+
if (token.type === 'text' || token.type === 'param' || token.type === 'glob') {
27+
(optional?.node.nodes ?? nodes).push(token);
28+
}
29+
}
30+
if (optional) {
31+
throw new Error(`Unbalanced paren at index: ${optional.index}`);
2832
}
2933
return nodes;
3034
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export type Token = { span: [number, number] } & (
2+
| { type: 'text'; value: string }
3+
| { type: 'param'; name?: string }
4+
| { type: 'glob'; name?: string }
5+
| { type: '(' | ')' }
6+
);

0 commit comments

Comments
 (0)