Skip to content

Commit

Permalink
Refactored pathname parser
Browse files Browse the repository at this point in the history
  • Loading branch information
smikhalevski committed Jul 10, 2024
1 parent 9c31edb commit 2e3014b
Show file tree
Hide file tree
Showing 7 changed files with 365 additions and 192 deletions.
301 changes: 200 additions & 101 deletions src/main/PathnameAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import { Dict } from './types';

export interface PathnameMatch {
/**
* A pathname that was matched.
* A pathname that was matched, beginning with a "/".
*/
pathname: string;

/**
* A pathname that should be matched by a nested route.
* A pathname that should be matched by a nested route, beginning with a "/".
*/
nestedPathname: string;

Expand All @@ -26,105 +26,38 @@ export class PathnameAdapter {
*/
readonly paramNames: ReadonlySet<string>;

/**
* An array with an odd number of strings, where even items are param names.
*/
protected _template;

/**
* The {@link !RegExp} that matches the template at the start of the pathname.
*/
protected _parts;
protected _flags;
protected _regExp;

/**
* Creates a new {@link PathnameAdapter} instance.
*
* Pattern can include params that conform `\$[_A-Za-z][_A-Za-z0-9]+`. For example: "$userId".
*
* By default, params match a non-empty pathname substring not-including "/". Follow a param with a "*" to make param
* match any character. For example: "$slug*".
*
* By default, params expect at least one character to be matched. To make param optional (so it can match zero
* characters) follow it by a "?". For example: "$userId?" or "$slug*?".
*
* To use "$" as a character in a pathname pattern, replace it with an {@link !encodeURIComponent encoded}
* representation: "%24".
*
* @param pathname A pathname pattern.
* @param isCaseSensitive If `true` then pathname is matched in a case-sensitive manner.
*/
constructor(pathname: string) {
const template = [];
constructor(pathname: string, isCaseSensitive = false) {
const template = parsePathname(pathname);
const paramNames = new Set<string>();

let i = 0;
let j = 0;
let charCode;

if (pathname.charAt(0) === '/') {
pathname = pathname.substring(1);
}
if (pathname.endsWith('/')) {
pathname = pathname.slice(0, -1);
}

let segment;
let pattern = '';

while ((i = pathname.indexOf('$', i)) !== -1) {
segment = pathname.substring(j, i);
pattern += escapeRegExp(segment);
template.push(segment);

j = ++i;

while (
((charCode = pathname.charCodeAt(i)),
(i > j && charCode >= 48 && charCode <= 57) /* 0-9 */ ||
(charCode >= 65 && charCode <= 90) /* A-Z */ ||
(charCode >= 97 && charCode <= 122) /* a-z */ ||
charCode === 95) /* _ */
) {
++i;
for (let i = 0; i < template.parts.length; ++i) {
if ((template.flags[i] & FLAG_PARAM) === FLAG_PARAM) {
paramNames.add(template.parts[i]);
}

if (i === j) {
throw new Error('Pathname param must have a name: ' + i);
}

segment = pathname.substring(j, i);

if (charCode === 42 /* * */) {
pattern += (charCode = pathname.charCodeAt(++i)) === 63 /* ? */ ? (++i, '(.*)') : '(.+)';
} else {
pattern += charCode === 63 /* ? */ ? (++i, '([^\\\\/]*)') : '([^\\\\/]+)';
}

paramNames.add(segment);
template.push(segment);

j = i;
}

if (j === 0) {
template.push(pathname);
pattern = escapeRegExp(pathname);
} else {
segment = pathname.substring(j);
pattern += escapeRegExp(segment);
template.push(segment);
}

this.paramNames = paramNames;

this._template = template;
this._regExp = pattern === '' ? /^/ : new RegExp('^\\/?' + pattern + '(?=\\/|$)', 'i');
this._parts = template.parts;
this._flags = template.flags;
this._regExp = createPathnameRegExp(template, isCaseSensitive);
}

/**
* Matches a pathname against a template and returns a match if pathname conforms.
* Matches a pathname against a pathname pattern.
*/
match(pathname: string): PathnameMatch | null {
const { _template } = this;
const { _parts, _flags } = this;

const match = this._regExp.exec(pathname);

Expand All @@ -134,50 +67,216 @@ export class PathnameAdapter {

let params: Dict | undefined;

if (_template.length !== 1) {
if (_parts.length !== 1) {
params = {};

for (let i = 1; i < _template.length; i += 2) {
params[_template[i]] = decodeURIComponent(match[(i + 1) >> 1]);
for (let i = 0, j = 1, value; i < _parts.length; ++i) {
if ((_flags[i] & FLAG_PARAM) !== FLAG_PARAM) {
continue;
}
value = match[j++];
params[_parts[i]] = value && decodeURIComponent(value);
}
}

const m = match[0];
const nestedPathname = pathname.substring(m.length);

return {
pathname: m,
nestedPathname: pathname.length > m.length + 1 ? pathname.substring(m.length) : '',
pathname: m === '' ? '/' : m,
nestedPathname:
nestedPathname.length === 0 || nestedPathname.charCodeAt(0) !== 47 ? '/' + nestedPathname : nestedPathname,
params,
};
}

/**
* Creates a pathname from a template by substituting params.
*
* The returned pathname never contains leading or trailing "/".
* Creates a pathname from a template by substituting params, beginning with a "/".
*/
toPathname(params: Dict | undefined): string {
const { _template } = this;
const { _parts, _flags } = this;

let pathname = '';

for (let i = 0, part, flag, value; i < _parts.length; i++) {
part = _parts[i];
flag = _flags[i];

if (_template.length !== 1 && params === undefined) {
throw new Error('Pathname params are required: ' + Array.from(this.paramNames).join(', '));
if ((flag & FLAG_PARAM) !== FLAG_PARAM) {
pathname += '/' + part;
continue;
}
if (
(params === undefined || (value = params[part]) === undefined || value === null || value === '') &&
(flag & FLAG_OPTIONAL) === FLAG_OPTIONAL
) {
continue;
}
if (typeof value !== 'string') {
throw new Error('Param must be a string: ' + part);
}

pathname +=
'/' + ((flag & FLAG_WILDCARD) === FLAG_WILDCARD ? encodePathname(value) : encodePathnameComponent(value));
}

let pathname = _template[0];
return pathname === '' ? '/' : pathname;
}
}

for (let i = 1, paramValue; i < _template.length; i += 2) {
paramValue = params![_template[i]];
const FLAG_PARAM = 1;
const FLAG_WILDCARD = 1 << 1;
const FLAG_OPTIONAL = 1 << 2;

if (typeof paramValue !== 'string') {
throw new Error('Pathname param must be a string: ' + _template[i]);
}
pathname += encodeURIComponent(paramValue) + _template[i + 1];
const STAGE_SEPARATOR = 0;
const STAGE_SEGMENT = 1;
const STAGE_PARAM = 2;
const STAGE_WILDCARD = 3;
const STAGE_OPTIONAL = 4;

/**
* A result of a pathname pattern parsing.
*/
interface Template {
/**
* A non-empty array of segments and param names extracted from a pathname pattern.
*/
parts: string[];

/**
* An array of bitmasks that holds {@link parts} metadata.
*/
flags: number[];
}

/**
* Parses pathname pattern as a template.
*/
export function parsePathname(pathname: string): Template {
const parts = [];
const flags = [];

let stage = STAGE_SEPARATOR;
let segmentIndex = 0;

for (let i = 0, charCode, paramIndex; i < pathname.length; ) {
switch (pathname.charCodeAt(i)) {
case 58 /* : */:
if (stage !== STAGE_SEPARATOR) {
throw new SyntaxError('Unexpected param at ' + i);
}

paramIndex = ++i;

while (
((charCode = pathname.charCodeAt(i)),
(i > paramIndex && charCode >= 48 && charCode <= 57) /* 0-9 */ ||
(charCode >= 65 && charCode <= 90) /* A-Z */ ||
(charCode >= 97 && charCode <= 122) /* a-z */ ||
charCode === 95 /* _ */ ||
charCode === 36) /* $ */
) {
++i;
}

if (paramIndex === i) {
throw new SyntaxError('Param must have a name at ' + i);
}

parts.push(pathname.substring(paramIndex, i));
flags.push(FLAG_PARAM);
stage = STAGE_PARAM;
break;

case 42 /* * */:
if (stage !== STAGE_PARAM) {
throw new SyntaxError('Unexpected wildcard flag at ' + i);
}
flags[flags.length - 1] |= FLAG_WILDCARD;
stage = STAGE_WILDCARD;
++i;
break;

case 63 /* ? */:
if (stage === STAGE_SEPARATOR || stage === STAGE_SEGMENT) {
parts.push(pathname.substring(segmentIndex, i));
flags.push(FLAG_OPTIONAL);
stage = STAGE_OPTIONAL;
++i;
break;
}
if (stage === STAGE_PARAM || stage === STAGE_WILDCARD) {
flags[flags.length - 1] |= FLAG_OPTIONAL;
stage = STAGE_OPTIONAL;
++i;
break;
}
throw new SyntaxError('Unexpected optional flag at ' + i);

case 47 /* / */:
if (i !== 0 && (stage === STAGE_SEPARATOR || stage === STAGE_SEGMENT)) {
parts.push(pathname.substring(segmentIndex, i));
flags.push(0);
}
stage = STAGE_SEPARATOR;
segmentIndex = ++i;
break;

default:
if (stage !== STAGE_SEPARATOR && stage !== STAGE_SEGMENT) {
throw new SyntaxError('Unexpected character at ' + i);
}
stage = STAGE_SEGMENT;
++i;
break;
}
}

if (stage === STAGE_SEPARATOR || stage === STAGE_SEGMENT) {
parts.push(pathname.substring(segmentIndex));
flags.push(0);
}

return { parts, flags };
}

/**
* Creates a {@link !RegExp} that matches a pathname template.
*/
export function createPathnameRegExp(template: Template, isCaseSensitive = false): RegExp {
const { parts, flags } = template;

let pattern = '^';

for (let i = 0, part, flag, segmentPattern; i < parts.length; ++i) {
part = parts[i];
flag = flags[i];

if ((flag & FLAG_PARAM) !== FLAG_PARAM) {
segmentPattern = part.length === 0 ? '/' : '/' + escapeRegExp(part);

pattern += (flag & FLAG_OPTIONAL) === FLAG_OPTIONAL ? '(?:' + segmentPattern + ')?' : segmentPattern;
continue;
}

return pathname;
if ((flag & FLAG_WILDCARD) === FLAG_WILDCARD) {
pattern += (flag & FLAG_OPTIONAL) === FLAG_OPTIONAL ? '(?:/(.+))?' : '/(.+)';
} else {
pattern += (flag & FLAG_OPTIONAL) === FLAG_OPTIONAL ? '(?:/([^/]+))?' : '/([^/]+)';
}
}

return new RegExp(pattern.endsWith('/') ? pattern : pattern + '(?=/|$)', isCaseSensitive ? '' : 'i');
}

function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

function encodePathname(str: string): string {
return str.replace(/[:*?]/g, encodeURIComponent);
}

function encodePathnameComponent(str: string): string {
return str.replace(/[:*?/]/g, encodeURIComponent);
}
Loading

0 comments on commit 2e3014b

Please sign in to comment.