From: Eduardo San Martin Morote Date: Tue, 10 Dec 2019 20:54:12 +0000 (+0100) Subject: feat: add wip path parser to replace path-to-regexp X-Git-Tag: v4.0.0-alpha.0~158 X-Git-Url: http://git.ipfire.org/?a=commitdiff_plain;h=42cef3f26186e512583c7458d21bf28786feb333;p=thirdparty%2Fvuejs%2Frouter.git feat: add wip path parser to replace path-to-regexp --- diff --git a/__tests__/matcher/path-parser.spec.ts b/__tests__/matcher/path-parser.spec.ts new file mode 100644 index 00000000..18d35abd --- /dev/null +++ b/__tests__/matcher/path-parser.spec.ts @@ -0,0 +1,186 @@ +import { + tokenizePath, + TokenType, + tokensToRegExp, +} from '../../src/matcher/tokenizer' + +describe('Path parser', () => { + describe('tokenizer', () => { + it('root', () => { + expect(tokenizePath('/')).toEqual([[]]) + }) + + it('static single', () => { + expect(tokenizePath('/home')).toEqual([ + [{ type: TokenType.Static, value: 'home' }], + ]) + }) + + it('static multiple', () => { + expect(tokenizePath('/one/two/three')).toEqual([ + [{ type: TokenType.Static, value: 'one' }], + [{ type: TokenType.Static, value: 'two' }], + [{ type: TokenType.Static, value: 'three' }], + ]) + }) + + it('param single', () => { + expect(tokenizePath('/:id')).toEqual([ + [ + { + type: TokenType.Param, + value: 'id', + repeatable: false, + optional: false, + }, + ], + ]) + }) + + it('param single?', () => { + expect(tokenizePath('/:id?')).toEqual([ + [ + { + type: TokenType.Param, + value: 'id', + repeatable: false, + optional: true, + }, + ], + ]) + }) + + it('param single+', () => { + expect(tokenizePath('/:id+')).toEqual([ + [ + { + type: TokenType.Param, + value: 'id', + repeatable: true, + optional: false, + }, + ], + ]) + }) + + it('param single*', () => { + expect(tokenizePath('/:id*')).toEqual([ + [ + { + type: TokenType.Param, + value: 'id', + repeatable: true, + optional: true, + }, + ], + ]) + }) + + it('param multiple', () => { + expect(tokenizePath('/:id/:other')).toEqual([ + [ + { + type: TokenType.Param, + value: 'id', + repeatable: false, + optional: false, + }, + ], + [ + { + type: TokenType.Param, + value: 'other', + repeatable: false, + optional: false, + }, + ], + ]) + }) + + it('param with static in between', () => { + expect(tokenizePath('/:id-:other')).toEqual([ + [ + { + type: TokenType.Param, + value: 'id', + repeatable: false, + optional: false, + }, + { + type: TokenType.Static, + value: '-', + }, + { + type: TokenType.Param, + value: 'other', + repeatable: false, + optional: false, + }, + ], + ]) + }) + + it('param with static beginning', () => { + expect(tokenizePath('/hey-:id')).toEqual([ + [ + { + type: TokenType.Static, + value: 'hey-', + }, + { + type: TokenType.Param, + value: 'id', + repeatable: false, + optional: false, + }, + ], + ]) + }) + + it('param with static end', () => { + expect(tokenizePath('/:id-end')).toEqual([ + [ + { + type: TokenType.Param, + value: 'id', + repeatable: false, + optional: false, + }, + { + type: TokenType.Static, + value: '-end', + }, + ], + ]) + }) + }) + + describe('tokensToRegexp', () => { + function matchRegExp( + expectedRe: string, + ...args: Parameters + ) { + const pathParser = tokensToRegExp(...args) + expect(expectedRe).toBe( + pathParser.re.toString().replace(/(:?^\/|\\|\/$)/g, '') + ) + } + + it('static', () => { + matchRegExp('^/home$', [[{ type: TokenType.Static, value: 'home' }]]) + }) + + it('param simple', () => { + matchRegExp('^/([^/]+)$', [ + [ + { + type: TokenType.Param, + value: 'id', + repeatable: false, + optional: false, + }, + ], + ]) + }) + }) +}) diff --git a/src/matcher/tokenizer.ts b/src/matcher/tokenizer.ts new file mode 100644 index 00000000..08f40f07 --- /dev/null +++ b/src/matcher/tokenizer.ts @@ -0,0 +1,181 @@ +export const enum TokenType { + Static, + Param, +} + +const enum TokenizerState { + Static, + Param, + EscapeNext, +} + +interface TokenStatic { + type: TokenType.Static + value: string +} + +interface TokenParam { + type: TokenType.Param + regex?: string + value: string +} + +interface TokenParam { + type: TokenType.Param + regex?: string + value: string + optional: boolean + repeatable: boolean +} + +type Token = TokenStatic | TokenParam + +// const ROOT_TOKEN: Token = { +// type: TokenType.Static, +// value: '/', +// } + +const VALID_PARAM_RE = /[a-zA-Z0-9_]/ + +export function tokenizePath(path: string): Array { + if (path === '/') return [[]] + // remove the leading slash + if (!path) throw new Error('An empty path cannot be tokenized') + + function crash(message: string) { + throw new Error(`ERR (${state})/"${buffer}": ${message}`) + } + + let state: TokenizerState = TokenizerState.Static + let previousState: TokenizerState = state + const tokens: Array = [] + // the segment will always be valid because we get into the initial state + // with the leading / + let segment!: Token[] + + function finalizeSegment() { + if (segment) tokens.push(segment) + segment = [] + } + + // index on the path + let i = 0 + // char at index + let char: string + // buffer of the value read + let buffer: string = '' + + function consumeBuffer() { + if (!buffer) return + + if (state === TokenizerState.Static) { + segment.push({ + type: TokenType.Static, + value: buffer, + }) + } else if (state === TokenizerState.Param) { + segment.push({ + type: TokenType.Param, + value: buffer, + repeatable: char === '*' || char === '+', + optional: char === '*' || char === '?', + }) + } else { + crash('Invalid state to consume buffer') + } + buffer = '' + } + + function addCharToBuffer() { + buffer += char + } + + while (i < path.length) { + char = path[i++] + + if (char === '\\') { + previousState = state + state = TokenizerState.EscapeNext + continue + } + + switch (state) { + case TokenizerState.Static: + if (char === '/') { + if (buffer) { + consumeBuffer() + } + finalizeSegment() + } else if (char === ':') { + consumeBuffer() + state = TokenizerState.Param + } else if (char === '{') { + // TODO: handle group + } else { + addCharToBuffer() + } + break + + case TokenizerState.EscapeNext: + addCharToBuffer() + state = previousState + break + + case TokenizerState.Param: + if (char === '(') { + // TODO: start custom regex + } else if (VALID_PARAM_RE.test(char)) { + addCharToBuffer() + } else { + consumeBuffer() + state = TokenizerState.Static + // go back one character if we were not modifying + if (char !== '*' && char !== '?' && char !== '+') i-- + } + break + + default: + crash('Unkwnonw state') + break + } + } + + consumeBuffer() + finalizeSegment() + + return tokens +} + +interface PathParser { + re: RegExp + score: number + keys: string[] +} + +export function tokensToRegExp(segments: Array): PathParser { + let score = 0 + let pattern = '^' + const keys: string[] = [] + + for (const segment of segments) { + pattern += '/' + + for (const token of segment) { + if (token.type === TokenType.Static) { + pattern += token.value + } else if (token.type === TokenType.Param) { + keys.push(token.value) + pattern += `([^/]+)` + // TODO: repeatable and others + } + } + } + + pattern += '$' + + return { + re: new RegExp(pattern), + score, + keys, + } +}