]> git.ipfire.org Git - thirdparty/vuejs/router.git/commitdiff
feat(matcher): add path ranking
authorEduardo San Martin Morote <posva13@gmail.com>
Thu, 11 Jul 2019 17:35:32 +0000 (19:35 +0200)
committerEduardo San Martin Morote <posva13@gmail.com>
Wed, 17 Jul 2019 11:42:36 +0000 (13:42 +0200)
__tests__/matcher-ranking.spec.js [new file with mode: 0644]
src/matcher.ts

diff --git a/__tests__/matcher-ranking.spec.js b/__tests__/matcher-ranking.spec.js
new file mode 100644 (file)
index 0000000..5cde0fc
--- /dev/null
@@ -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',
+    ])
+  })
+})
index 6d392889d6f51c4a3f24b751e91de216e8479dda..15539be2e155223d836bdf1fdfd02a1fdf9f337f 100644 (file)
@@ -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<NormalizedRouteRecord>,
+  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<pathToRegexp.Token[]> = []
+    // 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<RouteRecord>,
     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)
   }
 
   /**