From: Eduardo San Martin Morote Date: Thu, 11 Jul 2019 17:35:32 +0000 (+0200) Subject: feat(matcher): add path ranking X-Git-Tag: v4.0.0-alpha.0~300 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=8a29aa8b82bac72267b8d522d1a9917652019197;p=thirdparty%2Fvuejs%2Frouter.git feat(matcher): add path ranking --- diff --git a/__tests__/matcher-ranking.spec.js b/__tests__/matcher-ranking.spec.js new file mode 100644 index 00000000..5cde0fcf --- /dev/null +++ b/__tests__/matcher-ranking.spec.js @@ -0,0 +1,100 @@ +// @ts-check +require('./helper') +const expect = require('expect') +const { createRouteMatcher } = require('../src/matcher') +const { START_LOCATION_NORMALIZED } = require('../src/types') +const { normalizeRouteRecord } = require('./utils') + +/** @type {RouteComponent} */ +const component = null + +/** @typedef {import('../src/types').RouteRecord} RouteRecord */ +/** @typedef {import('../src/types').RouteComponent} RouteComponent */ +/** @typedef {import('../src/types').MatchedRouteRecord} MatchedRouteRecord */ +/** @typedef {import('../src/types').MatcherLocation} MatcherLocation */ +/** @typedef {import('../src/types').MatcherLocationRedirect} MatcherLocationRedirect */ +/** @typedef {import('../src/types').MatcherLocationNormalized} MatcherLocationNormalized */ +/** @typedef {import('path-to-regexp').RegExpOptions} RegExpOptions */ + +describe('createRouteMatcher', () => { + /** + * + * @param {string[]} paths + * @param {RegExpOptions} options + */ + function checkPathOrder(paths, options = {}) { + const matchers = paths + .map(path => + createRouteMatcher( + { + path, + components: { default: component }, + }, + null, + options + ) + ) + .sort((a, b) => b.score - a.score) + .map(matcher => matcher.record.path) + expect(matchers).toEqual(paths) + } + + it('orders a rest param with root', () => { + checkPathOrder(['/a/', '/a/:w(.*)', '/a']) + }) + + it('orders sub segments with params', () => { + checkPathOrder(['/a-b-c', '/a-:b-c', '/a-:b-:c', '/a-:b']) + }) + + it('works', () => { + checkPathOrder([ + '/a/b/c', + '/a/:b/c', + '/a/b', + '/a/:b', + '/:a/-:b', + '/:a/:b', + '/a', + '/a-:b', + '/a-:w(.*)', + '/:a-b', + '/:a-:b-:c', + '/:a-:b', + '/:a-:b(.*)', + '/:w', + '/:w+', + '/', + ]) + }) + + it('puts the wildcard at the end', () => { + checkPathOrder(['/', '/:rest(.*)']) + }) + + it('handles sub segments optional params', () => { + // TODO: /a/c should be be bigger than /a/c/:b? + checkPathOrder(['/a/d/c', '/a/b/c:b', '/a/c/:b?', '/a/c']) + }) + + it('handles optional in sub segments', () => { + checkPathOrder([ + '/a/__', + '/a/_2_', + '/a/_:b\\_', // the _ is escaped + // something like /a/_23_ + '/a/_:b(\\d)?_', + '/a/a_:b', + '/a/_:b_', // the _ is part of the identifier + ]) + }) + + it('works with long paths', () => { + checkPathOrder([ + '/a/b/c/d/e', + '/:k-foo/b/c/d/e', + '/:k/b/c/d/e', + '/:k/b/c/d/:j', + ]) + }) +}) diff --git a/src/matcher.ts b/src/matcher.ts index 6d392889..15539be2 100644 --- a/src/matcher.ts +++ b/src/matcher.ts @@ -20,6 +20,7 @@ interface RouteMatcher { // TODO: children so they can be removed // children: RouteMatcher[] keys: string[] + score: number } /** @@ -46,6 +47,195 @@ export function normalizeRecord( return { ...record } } +enum PathScore { + Segment = 4, // /a-segment + SubSegment = 2, // /multiple-:things-in-one-:segment + Static = 3, // /static + Dynamic = 2, // /:someId + Wildcard = -1, // /:namedWildcard(.*) + SubWildcard = 1, // Wildcard as a subsegment + Repeatable = -0.5, // /:w+ or /:w* + Optional = -4, // /:w? or /:w* + SubOptional = -0.1, // optional inside a subsegment /a-:w? or /a-:w* + Root = 1, // just / +} + +export function createRouteMatcher( + record: Readonly, + parent: RouteMatcher | void, + options: pathToRegexp.RegExpOptions +): RouteMatcher { + const keys: pathToRegexp.Key[] = [] + // options only use `delimiter` + const tokens = pathToRegexp.parse(record.path, options) + const re = pathToRegexp.tokensToRegExp(tokens, keys, options) + // we pass a copy because later on we are modifying the original token array + // to compute the score of routes + const resolve = pathToRegexp.tokensToFunction([...tokens]) + + let score = 0 + + // console.log(tokens) + // console.log('--- GROUPING ---') + + // special case for root path + if (tokens.length === 1 && tokens[0] === '/') { + score = 5 + } else { + // allows us to group tokens into one single segment + // it will point to the first token of the current group + let currentSegment = 0 + // we group them in arrays to process them later + const groups: Array = [] + // we skip the first element as it must be part of the first group + const token = tokens[0] + if (typeof token === 'string') { + // TODO: refactor code in loop (right now it is duplicated) + // we still need to check for / inside the string + // remove the empty string because of the leading slash + const sections = token.split('/').slice(1) + if (sections.length > 1) { + // the last one is going to replace currentSegment + const last = sections.pop() as string // ts complains but length >= 2 + // we need to finalize previous group but we cannot use current entry + // here we are sure that currentSegment < i because the token doesn't start with / + // assert(currentSegment < i) + const first = sections.shift() as string // ts complains but length >= 2 + // so we cut until the current segment and add the first section of current token as well + groups.push(tokens.slice(currentSegment, 0).concat('/' + first)) + // equivalent to + // groups.push(['/' + first]) + + // we add the remaining sections as static groups + for (const section of sections) { + groups.push(['/' + section]) + } + + // we replace current entry with the last section and reset current segment + tokens[0] = '/' + last + // currentSegment = 0 + } + } + + for (let i = 1; i < tokens.length; i++) { + const token = tokens[i] + if (typeof token === 'string') { + if (token.charAt(0) === '/') { + // finalize previous group and start a new one + groups.push(tokens.slice(currentSegment, i)) + currentSegment = i + } else { + // we still need to check for / inside the string + const sections = token.split('/') + if (sections.length > 1) { + // the last one is going to replace currentSegment + const last = sections.pop() as string // ts complains but length >= 2 + // we need to finalize previous group but we cannot use current entry + // here we are sure that currentSegment < i because the token doesn't start with / + // assert(currentSegment < i) + const first = sections.shift() as string // ts complains but length >= 2 + // so we cut until the current segment and add the first section of current token as well + groups.push(tokens.slice(currentSegment, i).concat(first)) + + // we add the remaining sections as static groups + for (const section of sections) { + groups.push(['/' + section]) + } + + // we replace current entry with the last section and reset current segment + tokens[i] = '/' + last + currentSegment = i + } + } + } else { + if (token.prefix.charAt(0) === '/') { + // finalize previous group and start a new one + groups.push(tokens.slice(currentSegment, i)) + currentSegment = i + } + } + } + + // add the remaining segment as one group + // TODO: refactor the handling of ending with static like /:a/b/c + if (currentSegment < tokens.length) { + let token: pathToRegexp.Token + if ( + tokens.length - currentSegment === 1 && + typeof (token = tokens[tokens.length - 1]) === 'string' + ) { + // the remaining group is a single string, so it must start with a leading / + const sections = token.split('/').slice(1) + // we add the remaining sections as static groups + for (const section of sections) { + groups.push(['/' + section]) + } + } else { + groups.push(tokens.slice(currentSegment)) + } + } + + function scoreForSegment(group: pathToRegexp.Token): number { + let score = PathScore.Segment + if (typeof group === 'string') { + score += group === '/' ? PathScore.Root : PathScore.Static + } else { + score += group.pattern === '.*' ? PathScore.Wildcard : PathScore.Dynamic + score += + +group.optional * PathScore.Optional + + +group.repeat * PathScore.Repeatable + if (typeof group.name === 'number') throw new Error('Name your param') + // keys.push(group.name) + } + return score + } + + function scoreForSubSegment(group: pathToRegexp.Token): number { + let score = 0 + if (typeof group === 'string') { + // in a sub segment, it doesn't matter if it's root or not + score += PathScore.Static + } else { + score += + group.pattern === '.*' ? PathScore.SubWildcard : PathScore.Dynamic + score += +group.optional * PathScore.SubOptional + if (typeof group.name === 'number') throw new Error('Name your param') + // keys.push(group.name) + } + return score + } + + for (const group of groups) { + // console.log(group) + if (group.length === 1) { + score += scoreForSegment(group[0]) + } else { + score += PathScore.Segment + PathScore.SubSegment + let multiplier = 1 / 10 + for (let i = 0; i < group.length; i++) { + score += scoreForSubSegment(group[i]) * multiplier + multiplier /= 10 + } + } + // segments.push('/' + section) + } + + // console.log(record.path, { score }) + // console.log('____'.repeat(20)) + } + + // create the object before hand so it can be passed to children + return { + parent, + record: record, + re, + // TODO: handle numbers differently. Maybe take the max one and say there are x unnamed keys + keys: keys.map(key => String(key.name)), + resolve, + score, + } +} + export class RouterMatcher { private matchers: RouteMatcher[] = [] @@ -59,7 +249,6 @@ export class RouterMatcher { record: Readonly, parent?: RouteMatcher ): void { - const keys: pathToRegexp.Key[] = [] const options: pathToRegexp.RegExpOptions = {} const recordCopy = normalizeRecord(record) @@ -75,16 +264,8 @@ export class RouterMatcher { } } - const re = pathToRegexp(recordCopy.path, keys, options) - // create the object before hand so it can be passed to children - const matcher: RouteMatcher = { - parent, - record: recordCopy, - re, - keys: keys.map(k => '' + k.name), - resolve: pathToRegexp.compile(recordCopy.path), - } + const matcher = createRouteMatcher(recordCopy, parent, options) if ('children' in record && record.children) { for (const childRecord of record.children) { @@ -92,7 +273,14 @@ export class RouterMatcher { } } - this.matchers.push(matcher) + this.insertMatcher(matcher) + } + + private insertMatcher(matcher: RouteMatcher) { + let i = 0 + while (i < this.matchers.length && matcher.score <= this.matchers[i].score) + i++ + this.matchers.splice(i, 0, matcher) } /**