diff --git a/bench/benches/serialize.js b/bench/benches/serialize.js new file mode 100644 index 0000000..230aa9c --- /dev/null +++ b/bench/benches/serialize.js @@ -0,0 +1,22 @@ +var RouteRecognizer = require('../../dist/route-recognizer'); + +var router = new RouteRecognizer(); + +router.map(function(match) { + var i = 1000; + while (i--) { + match('/posts/' + i).to('showPost' + i); + } +}); + +var serialized = JSON.parse(router.toJSON()); + +module.exports = { + name: 'Serialized', + fn: function() { + var router = new RouteRecognizer(serialized); + + // Look up time is constant + router.recognize('/posts/1'); + } +}; \ No newline at end of file diff --git a/dist/route-recognizer.js b/dist/route-recognizer.js index 415fdf9..93608bb 100644 --- a/dist/route-recognizer.js +++ b/dist/route-recognizer.js @@ -1,397 +1,504 @@ (function() { "use strict"; - function $$route$recognizer$dsl$$Target(path, matcher, delegate) { - this.path = path; - this.matcher = matcher; - this.delegate = delegate; - } - - $$route$recognizer$dsl$$Target.prototype = { - to: function(target, callback) { - var delegate = this.delegate; - - if (delegate && delegate.willAddRoute) { - target = delegate.willAddRoute(this.matcher.target, target); - } - - this.matcher.add(this.path, target); + var $$route$recognizer$normalizer$$PERCENT_ENCODED_VALUES = /%[a-fA-F0-9]{2}/g; - if (callback) { - if (callback.length === 0) { throw new Error("You must have an argument in the function passed to `to`"); } - this.matcher.addChild(this.path, target, callback, this.delegate); - } - return this; - } - }; + function $$route$recognizer$normalizer$$toUpper(str) { return str.toUpperCase(); } - function $$route$recognizer$dsl$$Matcher(target) { - this.routes = {}; - this.children = {}; - this.target = target; + // Turn percent-encoded values to upper case ("%3a" -> "%3A") + function $$route$recognizer$normalizer$$percentEncodedValuesToUpper(string) { + return string.replace($$route$recognizer$normalizer$$PERCENT_ENCODED_VALUES, $$route$recognizer$normalizer$$toUpper); } - $$route$recognizer$dsl$$Matcher.prototype = { - add: function(path, handler) { - this.routes[path] = handler; - }, - - addChild: function(path, target, callback, delegate) { - var matcher = new $$route$recognizer$dsl$$Matcher(target); - this.children[path] = matcher; - - var match = $$route$recognizer$dsl$$generateMatch(path, matcher, delegate); - - if (delegate && delegate.contextEntered) { - delegate.contextEntered(target, match); - } - - callback(match); - } - }; - - function $$route$recognizer$dsl$$generateMatch(startingPath, matcher, delegate) { - return function(path, nestedCallback) { - var fullPath = startingPath + path; - - if (nestedCallback) { - nestedCallback($$route$recognizer$dsl$$generateMatch(fullPath, matcher, delegate)); - } else { - return new $$route$recognizer$dsl$$Target(startingPath + path, matcher, delegate); - } - }; + // Normalizes percent-encoded values to upper-case and decodes percent-encoded + // values that are not reserved (like unicode characters). + // Safe to call multiple times on the same path. + function $$route$recognizer$normalizer$$normalizePath(path) { + return path.split('/') + .map($$route$recognizer$normalizer$$normalizeSegment) + .join('/'); } - function $$route$recognizer$dsl$$addRoute(routeArray, path, handler) { - var len = 0; - for (var i=0; i z`. For instance, "199" is smaller - // then "200", even though "y" and "z" (which are both 9) are larger than "0" (the value - // of (`b` and `c`). This is because the leading symbol, "2", is larger than the other - // leading symbol, "1". - // The rule is that symbols to the left carry more weight than symbols to the right - // when a number is written out as a string. In the above strings, the leading digit - // represents how many 100's are in the number, and it carries more weight than the middle - // number which represents how many 10's are in the number. - // This system of number magnitude works well for route specificity, too. A route written as - // `a/b/c` will be more specific than `x/y/z` as long as `a` is more specific than - // `x`, irrespective of the other parts. - // Because of this similarity, we assign each type of segment a number value written as a - // string. We can find the specificity of compound routes by concatenating these strings - // together, from left to right. After we have looped through all of the segments, - // we convert the string to a number. - specificity.val = ''; - - for (var i=0; i 58 + case 42: this.type = 'glob'; break; // * => 42 + default: + this.type = 'static'; + normalized = $$route$recognizer$segment$trie$node$$normalizePath(this.value); + // We match against a normalized value. + // Keep the original value for error messaging. + if (normalized !== this.value) { + this.originalValue = this.value; + this.value = normalized; + } + break; } + + this.handler = undefined; + this.childNodes = []; + this.parentNode = undefined; + } else { + this.id = value.id; + this.value = value.value; + this.originalValue = value.originalValue; + this.type = value.type; + this.handler = value.handler; + this.childNodes = value.childNodes || []; + this.parentNode = value.parentNode; } + } - specificity.val = +specificity.val; + $$route$recognizer$segment$trie$node$$SegmentTrieNode.prototype = { - return results; - } + // Naively add a new child to the current trie node. + append: function(trieNode) { + this.childNodes.push(trieNode); + trieNode.parentNode = this; + return this; + }, - // A State has a character specification and (`charSpec`) and a list of possible - // subsequent states (`nextStates`). - // - // If a State is an accepting state, it will also have several additional - // properties: - // - // * `regex`: A regular expression that is used to extract parameters from paths - // that reached this accepting state. - // * `handlers`: Information on how to convert the list of captures into calls - // to registered handlers with the specified parameters - // * `types`: How many static, dynamic or star segments in this route. Used to - // decide which route to use if multiple registered routes match a path. - // - // Currently, State is implemented naively by looping over `nextStates` and - // comparing a character specification against a character. A more efficient - // implementation would use a hash of keys pointing at one or more next states. - - function $$route$recognizer$$State(charSpec) { - this.charSpec = charSpec; - this.nextStates = []; - this.charSpecs = {}; - this.regex = undefined; - this.handlers = undefined; - this.specificity = undefined; - } + // Reduce the amount of space which is needed to represent the + // radix trie by collapsing common prefixes. + compact: function() { + if (this.childNodes.length === 0) { return; } + + // Depth-first compaction. + this.childNodes.forEach(function(trieNode) { + trieNode.compact(); + }); + + // Collapse sibling nodes. + this.childNodes = this.childNodes.filter(function(trieNode, index, siblingNodes) { + var segmentSeen = false; + + // Scan only segments before this one to see if we've already got a match. + for (var i = 0; i < index && segmentSeen === false; i++) { + segmentSeen = ( + siblingNodes[i].value === trieNode.value && + siblingNodes[i].handler === trieNode.handler && + (trieNode.name === siblingNodes[i].name || trieNode.name === undefined || siblingNodes[i].name === undefined) + ); + } - $$route$recognizer$$State.prototype = { - get: function(charSpec) { - if (this.charSpecs[charSpec.validChars]) { - return this.charSpecs[charSpec.validChars]; - } + if (segmentSeen) { + var targetNode = siblingNodes[i-1]; - var nextStates = this.nextStates; + // Reset the parentNode for each trieNode to the targetNode. + trieNode.childNodes.forEach(function(trieNode) { + trieNode.parentNode = targetNode; + }); - for (var i=0; i bscore; + }); + }, - // If this character specification repeats, insert the new state as a child - // of itself. Note that this will not trigger an infinite loop because each - // transition during recognition consumes a character. - if (charSpec.repeat) { - state.nextStates.push(state); + // Can't just blindly return itself. + // Minimizes individual object size. + // Only called at build time. + toJSON: function() { + var childNodeCount = this.childNodes.length; + + var result = { + id: this.id, + type: this.type, + value: this.value, + handler: this.handler, + }; + + if (this.originalValue) { + result.originalValue = this.originalValue; } - // Return the new state - return state; - }, + if (this.handler) { + result.handler = this.handler; + } - // Find a list of child states matching the next character - match: function(ch) { - var nextStates = this.nextStates, - child, charSpec, chars; + // Set up parentNode reference. + if (this.parentNode) { + result.parentNode = this.parentNode.id; + } - var returned = []; + // Set up childNodes references. + if (childNodeCount) { + result.childNodes = new Array(childNodeCount); + for (var i = 0; i < childNodeCount; i++) { + result.childNodes[i] = this.childNodes[i].id; + } + } - for (var i=0; i 0 && segmentIndex > 0); + if (nodeMatches) { + if (this.router.ENCODE_AND_DECODE_PATH_SEGMENTS) { + params[this.value.substring(1)] = decodeURIComponent(path.substr(0, segmentIndex)); + } else { + params[this.value.substring(1)] = path.substr(0, segmentIndex); + } + path = path.substring(segmentIndex + 1); + } + } + break; + case "glob": + // We can no longer do prefix matching. Prepare to traverse leaves. + + // It's possible to have multiple globbing routes in a single path. + // So maybe we already have a `regexPieces` array. + if (!regexPieces) { regexPieces = []; } + + if (isLeafNode) { + // If a glob is the leaf node we don't match a trailing slash. + regexPieces.push('(.+)(?:/?)'); + } else { + // Consume the segment. `regexPieces.join` adds the '/'. + regexPieces.push('(.+)'); + } + params[this.value.substring(1)] = { regex: true, index: regexPieces.length}; + break; + } - return nextStates; - } + // Short-circuit for nodes that can't possibly match. + if (!nodeMatches && !regexPieces) { return false; } - var $$route$recognizer$$oCreate = Object.create || function(proto) { - function F() {} - F.prototype = proto; - return new F(); - }; + if (this.handler) { + handlers.push({ + handler: this.handler, + params: params, + isDynamic: (this.type !== "static") + }); + } - function $$route$recognizer$$RecognizeResults(queryParams) { - this.queryParams = queryParams || {}; - } - $$route$recognizer$$RecognizeResults.prototype = $$route$recognizer$$oCreate({ - splice: Array.prototype.splice, - slice: Array.prototype.slice, - push: Array.prototype.push, - length: 0, - queryParams: null - }); + var nextParams = this.handler ? {} : params; - function $$route$recognizer$$findHandler(state, path, queryParams) { - var handlers = state.handlers, regex = state.regex; - var captures = path.match(regex), currentCapture = 1; - var result = new $$route$recognizer$$RecognizeResults(queryParams); + // Depth-first traversal of childNodes. No-op for leaf nodes. + for (var i = 0; i < this.childNodes.length; i++) { + nextNode = this.childNodes[i].walk(path, handlers, nextParams, regexPieces); - result.length = handlers.length; + // Stop traversing once we have a match since we're sorted by specificity. + if (!!nextNode) { break; } + } - for (var i=0; i= 0; i--) { + trieNode = segments[i]; + + if (trieNode.type === 'static') { + if (trieNode.value === '') { continue; } + output += '/' + trieNode.value; + } else if (trieNode.type === 'param') { + if (this.ENCODE_AND_DECODE_PATH_SEGMENTS) { + output += '/' + encodeURIComponent(params[trieNode.value.substr(1)]); + } else { + output += '/' + params[trieNode.value.substr(1)]; + } + } else if (trieNode.type === 'glob') { + output += '/' + params[trieNode.value.substr(1)]; + } else { + output += '/' + params[trieNode.value.substr(1)]; + } } - if (output.charAt(0) !== '/') { output = '/' + output; } - if (params && params.queryParams) { - output += this.generateQueryString(params.queryParams, route.handlers); + output += this.generateQueryString(params.queryParams); } + // "/".charCodeAt(0) === 47 + if (output.charCodeAt(0) !== 47) { + output = '/' + output; + } return output; }, - generateQueryString: function(params, handlers) { + generateQueryString: function(params) { var pairs = []; var keys = []; for(var key in params) { @@ -520,7 +660,7 @@ continue; } var pair = encodeURIComponent(key); - if ($$route$recognizer$$isArray(value)) { + if ($$route$recognizer$polyfills$$isArray(value)) { for (var j = 0; j < value.length; j++) { var arrayPair = key + '[]' + '=' + encodeURIComponent(value[j]); pairs.push(arrayPair); @@ -536,6 +676,12 @@ return "?" + pairs.join("&"); }, + map: function(callback, addRouteCallback) { + this.compacted = false; + this.addRouteCallback = addRouteCallback; + callback($$route$recognizer$polyfills$$bind($$route$recognizer$segment$trie$node$$matcher('map'), this.rootState)); + }, + parseQueryString: function(queryString) { var pairs = queryString.split("&"), queryParams = {}; for(var i=0; i < pairs.length; i++) { @@ -567,56 +713,84 @@ }, recognize: function(path) { - var states = [ this.rootState ], - pathLen, i, l, queryStart, queryParams = {}, - isSlashDropped = false; + if (!this.compacted) { this.rootState.compact(); this.compacted = true; } + + var hashStart = path.indexOf('#'); + if (hashStart !== -1) { + path = path.substr(0, hashStart); + } - queryStart = path.indexOf('?'); + var queryString, queryParams; + var queryStart = path.indexOf('?'); if (queryStart !== -1) { - var queryString = path.substr(queryStart + 1, path.length); + queryString = path.substr(queryStart + 1, path.length); path = path.substr(0, queryStart); queryParams = this.parseQueryString(queryString); } - path = decodeURI(path); - - if (path.charAt(0) !== "/") { path = "/" + path; } + // "/".charCodeAt(0) === 47 + if (path.charCodeAt(0) === 47) { + path = path.substr(1); + } - pathLen = path.length; - if (pathLen > 1 && path.charAt(pathLen - 1) === "/") { - path = path.substr(0, pathLen - 1); - isSlashDropped = true; + if (this.ENCODE_AND_DECODE_PATH_SEGMENTS) { + path = $$route$recognizer$$normalizePath(path); + } else { + path = decodeURI(path); } - for (i=0; i z`. For instance, \"199\" is smaller\n // then \"200\", even though \"y\" and \"z\" (which are both 9) are larger than \"0\" (the value\n // of (`b` and `c`). This is because the leading symbol, \"2\", is larger than the other\n // leading symbol, \"1\".\n // The rule is that symbols to the left carry more weight than symbols to the right\n // when a number is written out as a string. In the above strings, the leading digit\n // represents how many 100's are in the number, and it carries more weight than the middle\n // number which represents how many 10's are in the number.\n // This system of number magnitude works well for route specificity, too. A route written as\n // `a/b/c` will be more specific than `x/y/z` as long as `a` is more specific than\n // `x`, irrespective of the other parts.\n // Because of this similarity, we assign each type of segment a number value written as a\n // string. We can find the specificity of compound routes by concatenating these strings\n // together, from left to right. After we have looped through all of the segments,\n // we convert the string to a number.\n specificity.val = '';\n\n for (var i=0; i 2 && key.slice(keyLength -2) === '[]') {\n isArray = true;\n key = key.slice(0, keyLength - 2);\n if(!queryParams[key]) {\n queryParams[key] = [];\n }\n }\n value = pair[1] ? decodeQueryParamPart(pair[1]) : '';\n }\n if (isArray) {\n queryParams[key].push(value);\n } else {\n queryParams[key] = value;\n }\n }\n return queryParams;\n },\n\n recognize: function(path) {\n var states = [ this.rootState ],\n pathLen, i, l, queryStart, queryParams = {},\n isSlashDropped = false;\n\n queryStart = path.indexOf('?');\n if (queryStart !== -1) {\n var queryString = path.substr(queryStart + 1, path.length);\n path = path.substr(0, queryStart);\n queryParams = this.parseQueryString(queryString);\n }\n\n path = decodeURI(path);\n\n if (path.charAt(0) !== \"/\") { path = \"/\" + path; }\n\n pathLen = path.length;\n if (pathLen > 1 && path.charAt(pathLen - 1) === \"/\") {\n path = path.substr(0, pathLen - 1);\n isSlashDropped = true;\n }\n\n for (i=0; i \"%3A\")\nfunction percentEncodedValuesToUpper(string) {\n return string.replace(PERCENT_ENCODED_VALUES, toUpper);\n}\n\n// Normalizes percent-encoded values to upper-case and decodes percent-encoded\n// values that are not reserved (like unicode characters).\n// Safe to call multiple times on the same path.\nfunction normalizePath(path) {\n return path.split('/')\n .map(normalizeSegment)\n .join('/');\n}\n\nfunction percentEncode(char) {\n return '%' + charToHex(char);\n}\n\nfunction charToHex(char) {\n return char.charCodeAt(0).toString(16).toUpperCase();\n}\n\n// Decodes percent-encoded values in the string except those\n// characters in `reservedHex`, where `reservedHex` is an array of 2-character\n// percent-encodings\nfunction decodeURIComponentExcept(string, reservedHex) {\n if (string.indexOf('%') === -1) {\n // If there is no percent char, there is no decoding that needs to\n // be done and we exit early\n return string;\n }\n string = percentEncodedValuesToUpper(string);\n\n var result = '';\n var buffer = '';\n var idx = 0;\n while (idx < string.length) {\n var pIdx = string.indexOf('%', idx);\n\n if (pIdx === -1) { // no percent char\n buffer += string.slice(idx);\n break;\n } else { // found percent char\n buffer += string.slice(idx, pIdx);\n idx = pIdx + 3;\n\n var hex = string.slice(pIdx + 1, pIdx + 3);\n var encoded = '%' + hex;\n\n if (reservedHex.indexOf(hex) === -1) {\n // encoded is not in reserved set, add to buffer\n buffer += encoded;\n } else {\n result += decodeURIComponent(buffer);\n buffer = '';\n result += encoded;\n }\n }\n }\n result += decodeURIComponent(buffer);\n return result;\n}\n\n// Leave these characters in encoded state in segments\nvar reservedSegmentChars = ['%', '/'];\nvar reservedHex = reservedSegmentChars.map(charToHex);\n\nfunction normalizeSegment(segment) {\n return decodeURIComponentExcept(segment, reservedHex);\n}\n\nvar Normalizer = {\n normalizeSegment: normalizeSegment,\n normalizePath: normalizePath\n};\n\nexport default Normalizer;\n","var oCreate = Object.create || function(proto) {\n function F() {}\n F.prototype = proto;\n return new F();\n};\n\nfunction bind(fn, scope) {\n return function() {\n return fn.apply(scope, arguments);\n };\n}\n\nfunction isArray(test) {\n return Object.prototype.toString.call(test) === \"[object Array]\";\n}\n\nexport { bind, isArray, oCreate };\n","import { oCreate } from './polyfills';\n\n// This object is the accumulator for handlers when recognizing a route.\n// It's nothing more than an array with a bonus property.\nfunction RecognizeResults(queryParams) {\n this.queryParams = queryParams || {};\n}\nRecognizeResults.prototype = oCreate({\n splice: Array.prototype.splice,\n slice: Array.prototype.slice,\n push: Array.prototype.push,\n pop: Array.prototype.pop,\n length: 0,\n queryParams: null\n});\n\nexport default RecognizeResults;\n","import Normalizer from './normalizer';\nimport { bind, isArray } from './polyfills';\n\nvar normalizePath = Normalizer.normalizePath;\n\nvar router;\n\n/**\n Matcher is just a clever recursive function that \n */\nfunction matcher(source) {\n return function(path, callback) {\n var leaf;\n if (source === 'map' && this === this.router.rootState) {\n router = this.router;\n leaf = new SegmentTrieNode({ addRouteCallback: true, nodes: [] }, '');\n } else {\n leaf = this;\n }\n\n var segments = path.replace(/^\\//, '').split('/');\n\n // As we're adding segments we need to track the current leaf.\n for (var i = 0; i < segments.length; i++) {\n segments[i] = new SegmentTrieNode(this.router, segments[i]);\n\n leaf.append(segments[i]);\n leaf = segments[i];\n }\n\n if (callback) {\n // No handler, delegate back to the TrieNode's `to` method.\n leaf.to(undefined, callback, source);\n }\n\n return leaf;\n };\n}\n\n/**\nSegmentTrieNode is simply a radix trie where each radix\ncorresponds to a path segment in the RouteRecognizer microsyntax.\n*/\nfunction SegmentTrieNode(router, value) {\n var normalized;\n\n // Maintain a reference to the router so we can grab serialized\n // nodes off of it in case we're not fully initialized.\n this.router = router;\n\n // `value` is either a string or a serialized SegmentTrieNode.\n if (typeof value === 'string') {\n this.id = router.nodes.push(this) - 1;\n this.value = value;\n this.originalValue = undefined;\n\n switch (this.value.charCodeAt(0)) {\n case 58: this.type = 'param'; break; // : => 58\n case 42: this.type = 'glob'; break; // * => 42\n default:\n this.type = 'static';\n normalized = normalizePath(this.value);\n // We match against a normalized value.\n // Keep the original value for error messaging.\n if (normalized !== this.value) {\n this.originalValue = this.value;\n this.value = normalized;\n }\n break;\n }\n\n this.handler = undefined;\n this.childNodes = [];\n this.parentNode = undefined; \n } else {\n this.id = value.id;\n this.value = value.value;\n this.originalValue = value.originalValue;\n this.type = value.type;\n this.handler = value.handler;\n this.childNodes = value.childNodes || [];\n this.parentNode = value.parentNode;\n }\n}\n\nSegmentTrieNode.prototype = {\n\n // Naively add a new child to the current trie node.\n append: function(trieNode) {\n this.childNodes.push(trieNode);\n trieNode.parentNode = this;\n return this;\n },\n\n // Reduce the amount of space which is needed to represent the\n // radix trie by collapsing common prefixes.\n compact: function() {\n if (this.childNodes.length === 0) { return; }\n\n // Depth-first compaction.\n this.childNodes.forEach(function(trieNode) {\n trieNode.compact();\n });\n\n // Collapse sibling nodes.\n this.childNodes = this.childNodes.filter(function(trieNode, index, siblingNodes) {\n var segmentSeen = false;\n\n // Scan only segments before this one to see if we've already got a match.\n for (var i = 0; i < index && segmentSeen === false; i++) {\n segmentSeen = (\n siblingNodes[i].value === trieNode.value &&\n siblingNodes[i].handler === trieNode.handler &&\n (trieNode.name === siblingNodes[i].name || trieNode.name === undefined || siblingNodes[i].name === undefined)\n );\n }\n\n if (segmentSeen) {\n var targetNode = siblingNodes[i-1];\n\n // Reset the parentNode for each trieNode to the targetNode.\n trieNode.childNodes.forEach(function(trieNode) {\n trieNode.parentNode = targetNode;\n });\n\n targetNode.name = targetNode.name || trieNode.name;\n\n // Concat the childNodes of the active trieNode with the targetNode.\n targetNode.childNodes = targetNode.childNodes.concat(trieNode.childNodes);\n\n // Then re-compact the joined trie.\n targetNode.compact();\n }\n\n return !segmentSeen;\n });\n\n // Sort nodes to get an approximation of specificity.\n this.childNodes.sort(function(a, b) {\n var ascore, bscore;\n switch (a.type) {\n case \"static\": ascore = 0; break;\n case \"param\": ascore = 1; break;\n case \"glob\": ascore = 2; break;\n }\n switch (b.type) {\n case \"static\": bscore = 0; break;\n case \"param\": bscore = 1; break;\n case \"glob\": bscore = 2; break;\n }\n\n return ascore > bscore;\n });\n },\n\n // Can't just blindly return itself.\n // Minimizes individual object size.\n // Only called at build time.\n toJSON: function() {\n var childNodeCount = this.childNodes.length;\n\n var result = {\n id: this.id,\n type: this.type,\n value: this.value,\n handler: this.handler,\n };\n\n if (this.originalValue) {\n result.originalValue = this.originalValue;\n }\n\n if (this.handler) {\n result.handler = this.handler;\n }\n\n // Set up parentNode reference.\n if (this.parentNode) {\n result.parentNode = this.parentNode.id;\n }\n\n // Set up childNodes references.\n if (childNodeCount) {\n result.childNodes = new Array(childNodeCount);\n for (var i = 0; i < childNodeCount; i++) {\n result.childNodes[i] = this.childNodes[i].id;\n }\n }\n\n return result;\n },\n\n /**\n Binds a handler to this trie node.\n If it receives a callback it will continue matching.\n @public\n */\n to: function(handler, callback, source) {\n this.handler = handler;\n\n if (handler && this.router.addRouteCallback && source !== 'add') {\n var routes = [];\n var trieNode = this;\n var current = {\n path: '/' + trieNode.value,\n handler: trieNode.handler\n };\n\n while (trieNode = trieNode.parentNode) {\n if (trieNode.handler) {\n if (current) {\n routes.unshift(current);\n current = {\n path: '/' + trieNode.value,\n handler: trieNode.handler\n };\n } else {\n current.path = trieNode.value === '' ? current.path : '/' + trieNode.value + current.path;\n }\n } else {\n current.path = trieNode.value === '' ? current.path : '/' + trieNode.value + current.path;\n }\n }\n\n routes.unshift(current);\n this.router.addRouteCallback(router, routes);\n }\n\n if (callback) {\n if (callback.length === 0) { throw new Error(\"You must have an argument in the function passed to `to`\"); }\n callback(bind(matcher(source), this));\n }\n\n return this;\n },\n\n /**\n Our goal is to try and match based upon the node type. For non-globbing\n routes we can simply pop a segment off of the path and continue, eliminating\n entire branches as we go. Average number of comparisons is:\n `Number of Trie Nodes / Average Depth / 2`\n\n If we reach a globbing route we have to change strategy and traverse to all\n descendent leaf nodes until we find a match. As we traverse we build up a\n regular expression that would match beginning with that globbing route. We\n leverage the regular expression to handle the mechanics of greedy pattern\n matching with back-tracing. The average number of comparisons beyond a\n globbing route:\n `Number of Trie Nodes / 2`\n\n This could be optimized further to do O(1) matching for non-globbing\n segments but that is overkill for this use case.\n */\n walk: function(path, handlers, params, regexPieces) {\n var isLeafNode = (this.childNodes.length === 0);\n var isTerminalNode = this.handler;\n var nodeMatches = false;\n var nextNode = this;\n var consumed = false;\n var segmentIndex = 0;\n\n // Identify the node type so we know how to match it.\n switch (this.type) {\n case \"static\":\n if (regexPieces) {\n // If we're descended from a globbing route.\n regexPieces.push(this.value);\n } else {\n // Matches if the path to recognize is identical to the node value.\n segmentIndex = this.value.length;\n nodeMatches = path.indexOf(this.value) === 0;\n if (nodeMatches) {\n path = path.substring(segmentIndex);\n\n // \"/\".charCodeAt(0) === 47\n if (path.charCodeAt(0) === 47) {\n path = path.substr(1);\n }\n }\n }\n break;\n case \"param\":\n if (regexPieces) {\n // If we're descended from a globbing route.\n regexPieces.push('([^/]+)');\n params[this.value.substring(1)] = { regex: true, index: regexPieces.length};\n } else {\n // Valid for '/' to not appear, or appear anywhere but the 0th index.\n // 0 length or 0th index would result in an non-matching empty param.\n segmentIndex = path.indexOf('/');\n if (segmentIndex === -1) { segmentIndex = path.length; }\n\n nodeMatches = (path.length > 0 && segmentIndex > 0);\n if (nodeMatches) {\n if (this.router.ENCODE_AND_DECODE_PATH_SEGMENTS) {\n params[this.value.substring(1)] = decodeURIComponent(path.substr(0, segmentIndex));\n } else {\n params[this.value.substring(1)] = path.substr(0, segmentIndex);\n }\n path = path.substring(segmentIndex + 1);\n }\n }\n break;\n case \"glob\":\n // We can no longer do prefix matching. Prepare to traverse leaves.\n\n // It's possible to have multiple globbing routes in a single path.\n // So maybe we already have a `regexPieces` array.\n if (!regexPieces) { regexPieces = []; }\n\n if (isLeafNode) {\n // If a glob is the leaf node we don't match a trailing slash.\n regexPieces.push('(.+)(?:/?)');\n } else {\n // Consume the segment. `regexPieces.join` adds the '/'.\n regexPieces.push('(.+)');\n }\n params[this.value.substring(1)] = { regex: true, index: regexPieces.length};\n break;\n }\n\n // Short-circuit for nodes that can't possibly match.\n if (!nodeMatches && !regexPieces) { return false; }\n\n if (this.handler) {\n handlers.push({\n handler: this.handler,\n params: params,\n isDynamic: (this.type !== \"static\")\n });\n }\n\n var nextParams = this.handler ? {} : params;\n\n // Depth-first traversal of childNodes. No-op for leaf nodes.\n for (var i = 0; i < this.childNodes.length; i++) {\n nextNode = this.childNodes[i].walk(path, handlers, nextParams, regexPieces);\n\n // Stop traversing once we have a match since we're sorted by specificity.\n if (!!nextNode) { break; }\n }\n\n // If we're at a terminal node find out if we've consumed the entire path.\n if (isTerminalNode) {\n if (regexPieces) {\n var myregex = new RegExp('^'+regexPieces.join('/')+'$');\n var matches = myregex.exec(path);\n consumed = !!matches;\n \n if (consumed) {\n // Need to move matches to the correct params location.\n for (var j = 0; j < handlers.length; j++) {\n for (var x in handlers[i].params) {\n if (handlers[i].params[x].regex) {\n handlers[i].params[x] = matches[handlers[i].params[x].index];\n }\n }\n }\n } else {\n // We pushed a segment onto the regexPieces, but this wasn't a match.\n // Pop it back off for the next go-round.\n regexPieces.pop();\n }\n } else {\n consumed = (path.length === 0);\n }\n }\n\n // `consumed` is false unless set above.\n if (isLeafNode && !consumed) {\n if (this.handler) { handlers.pop(); }\n return false;\n } else {\n return nextNode;\n }\n },\n\n wire: function() {\n this.parentNode = this.router.nodes[this.parentNode];\n for (var i = 0; i < this.childNodes.length; i++) {\n this.childNodes[i] = this.router.nodes[this.childNodes[i]];\n }\n }\n};\n\nexport { matcher };\nexport default SegmentTrieNode;","import Normalizer from './route-recognizer/normalizer';\nimport RecognizeResults from './route-recognizer/recognize-results';\nimport SegmentTrieNode from './route-recognizer/segment-trie-node';\nimport { matcher } from './route-recognizer/segment-trie-node';\nimport { bind, isArray } from './route-recognizer/polyfills';\n\nvar normalizePath = Normalizer.normalizePath;\nvar normalizeSegment = Normalizer.normalizeSegment;\n\nfunction decodeQueryParamPart(part) {\n // http://www.w3.org/TR/html401/interact/forms.html#h-17.13.4.1\n part = part.replace(/\\+/gm, '%20');\n var result;\n try {\n result = decodeURIComponent(part);\n } catch(error) {result = '';}\n return result;\n}\n\nfunction RouteRecognizer(serialized) {\n this.ENCODE_AND_DECODE_PATH_SEGMENTS = RouteRecognizer.ENCODE_AND_DECODE_PATH_SEGMENTS;\n this.names = {};\n\n if (serialized) {\n this.compacted = true;\n this.nodes = new Array(serialized.nodes.length);\n\n for (var i = 0; i < serialized.nodes.length; i++) {\n if (!serialized.nodes[i]) { continue; }\n this.nodes[i] = new SegmentTrieNode(this, serialized.nodes[i]);\n }\n\n for (i = 0; i < serialized.nodes.length; i++) {\n if (!serialized.nodes[i]) { continue; }\n this.nodes[i].wire();\n }\n\n for (var x in serialized.names) {\n if (!serialized.names.hasOwnProperty(x)) { return; }\n this.names[x] = this.nodes[serialized.names[x]];\n }\n\n this.rootState = this.nodes[serialized.rootState];\n } else {\n this.compacted = false;\n this.nodes = [];\n this.rootState = new SegmentTrieNode(this, '');\n }\n}\n\nRouteRecognizer.prototype = {\n add: function(routes, options) {\n this.compacted = false;\n options = options || {};\n var leaf = this.rootState;\n\n // Go through each passed in route and call the matcher with it.\n for (var i = 0; i < routes.length; i++) {\n leaf = matcher('add').call(leaf, routes[i].path);\n leaf.to(routes[i].handler, undefined, 'add');\n }\n leaf.name = options.as;\n this.names[options.as] = leaf;\n },\n\n handlersFor: function(name) {\n var trieNode = this.names[name];\n\n if (!trieNode) { throw new Error(\"There is no route named \" + name); }\n\n var handlers = [];\n var current = {\n handler: trieNode.handler,\n names: []\n };\n\n if (trieNode.type === 'param') {\n current.names.push({ name: trieNode.value.substr(1), decode: true });\n }\n if (trieNode.type === 'glob') {\n current.names.push({ name: trieNode.value.substr(1), decode: false });\n }\n\n while (trieNode = trieNode.parentNode) {\n if (trieNode.handler) {\n handlers.push(current);\n\n current = {\n handler: trieNode.handler,\n names: []\n };\n }\n if (trieNode.type === 'param') {\n current.names.unshift({ name: trieNode.value.substr(1), decode: true });\n }\n if (trieNode.type === 'glob') {\n current.names.unshift({ name: trieNode.value.substr(1), decode: false });\n }\n }\n\n handlers.push(current);\n return handlers.reverse();\n },\n\n hasRoute: function(name) {\n return !!this.names[name];\n },\n\n generate: function(name, params) {\n if (!this.compacted) { this.rootState.compact(); this.compacted = true; }\n\n var output = \"\";\n var trieNode = this.names[name];\n\n if (!trieNode) { throw new Error(\"There is no route named \" + name); }\n\n var segments = [];\n do {\n // `push` is much faster than `unshift`\n segments.push(trieNode);\n } while (trieNode = trieNode.parentNode);\n\n // But it does mean we have to iterate over these backward.\n for (var i = segments.length - 1; i >= 0; i--) {\n trieNode = segments[i];\n\n if (trieNode.type === 'static') {\n if (trieNode.value === '') { continue; }\n output += '/' + trieNode.value; \n } else if (trieNode.type === 'param') {\n if (this.ENCODE_AND_DECODE_PATH_SEGMENTS) {\n output += '/' + encodeURIComponent(params[trieNode.value.substr(1)]);\n } else {\n output += '/' + params[trieNode.value.substr(1)];\n }\n } else if (trieNode.type === 'glob') {\n output += '/' + params[trieNode.value.substr(1)];\n } else {\n output += '/' + params[trieNode.value.substr(1)]; \n }\n }\n\n if (params && params.queryParams) {\n output += this.generateQueryString(params.queryParams);\n }\n\n // \"/\".charCodeAt(0) === 47\n if (output.charCodeAt(0) !== 47) {\n output = '/' + output;\n }\n return output;\n },\n\n generateQueryString: function(params) {\n var pairs = [];\n var keys = [];\n for(var key in params) {\n if (params.hasOwnProperty(key)) {\n keys.push(key);\n }\n }\n keys.sort();\n for (var i = 0; i < keys.length; i++) {\n key = keys[i];\n var value = params[key];\n if (value == null) {\n continue;\n }\n var pair = encodeURIComponent(key);\n if (isArray(value)) {\n for (var j = 0; j < value.length; j++) {\n var arrayPair = key + '[]' + '=' + encodeURIComponent(value[j]);\n pairs.push(arrayPair);\n }\n } else {\n pair += \"=\" + encodeURIComponent(value);\n pairs.push(pair);\n }\n }\n\n if (pairs.length === 0) { return ''; }\n\n return \"?\" + pairs.join(\"&\");\n },\n\n map: function(callback, addRouteCallback) {\n this.compacted = false;\n this.addRouteCallback = addRouteCallback;\n callback(bind(matcher('map'), this.rootState));\n },\n\n parseQueryString: function(queryString) {\n var pairs = queryString.split(\"&\"), queryParams = {};\n for(var i=0; i < pairs.length; i++) {\n var pair = pairs[i].split('='),\n key = decodeQueryParamPart(pair[0]),\n keyLength = key.length,\n isArray = false,\n value;\n if (pair.length === 1) {\n value = 'true';\n } else {\n //Handle arrays\n if (keyLength > 2 && key.slice(keyLength -2) === '[]') {\n isArray = true;\n key = key.slice(0, keyLength - 2);\n if(!queryParams[key]) {\n queryParams[key] = [];\n }\n }\n value = pair[1] ? decodeQueryParamPart(pair[1]) : '';\n }\n if (isArray) {\n queryParams[key].push(value);\n } else {\n queryParams[key] = value;\n }\n }\n return queryParams;\n },\n\n recognize: function(path) {\n if (!this.compacted) { this.rootState.compact(); this.compacted = true; }\n\n var hashStart = path.indexOf('#');\n if (hashStart !== -1) {\n path = path.substr(0, hashStart);\n }\n\n var queryString, queryParams;\n var queryStart = path.indexOf('?');\n if (queryStart !== -1) {\n queryString = path.substr(queryStart + 1, path.length);\n path = path.substr(0, queryStart);\n queryParams = this.parseQueryString(queryString);\n }\n\n // \"/\".charCodeAt(0) === 47\n if (path.charCodeAt(0) === 47) {\n path = path.substr(1);\n }\n\n if (this.ENCODE_AND_DECODE_PATH_SEGMENTS) {\n path = normalizePath(path);\n } else {\n path = decodeURI(path);\n }\n\n var handlers = new RecognizeResults(queryParams);\n var trieNode = this.rootState.walk(path, handlers, {});\n\n if (trieNode) {\n return handlers;\n } else {\n return null;\n }\n },\n\n toJSON: function() {\n if (!this.compacted) { this.rootState.compact(); this.compacted = true; }\n\n // Rebuild the names property as a series of ID references.\n var names = {};\n for (var x in this.names) {\n if (!this.names.hasOwnProperty(x)) { return; }\n names[x] = this.names[x].id;\n }\n\n // Could have unnecessary references after compacting.\n var parentsChildren = [];\n for (var i = 0; i < this.nodes.length; i++) {\n // Skip the root state.\n if (!this.nodes[i] || !this.nodes[i].parentNode) { continue; }\n\n // Reduce childNodes to a collection of IDs.\n parentsChildren = this.nodes[i].parentNode.childNodes.map(function(trieNode) { return trieNode.id; });\n\n // If we don't find it the current ID on the parent, drop it.\n if (!~parentsChildren.indexOf(this.nodes[i].id)) {\n this.nodes[i] = undefined;\n }\n }\n\n // Return an object which can be rehydrated.\n return {\n names: names,\n rootState: this.rootState.id,\n nodes: this.nodes\n };\n }\n};\n\nRouteRecognizer.VERSION = '0.1.11';\n\n// Set to false to opt-out of encoding and decoding path segments.\n// See https://github.com/tildeio/route-recognizer/pull/55\nRouteRecognizer.ENCODE_AND_DECODE_PATH_SEGMENTS = true;\n\nRouteRecognizer.Normalizer = Normalizer;\n\nexport default RouteRecognizer;\n","import RouteRecognizer from './route-recognizer';\n\n/* global define:true module:true window: true */\nif (typeof define === 'function' && define['amd']) {\n define('route-recognizer', function() { return RouteRecognizer; });\n} else if (typeof module !== 'undefined' && module['exports']) {\n module['exports'] = RouteRecognizer;\n} else if (typeof this !== 'undefined') {\n this['RouteRecognizer'] = RouteRecognizer;\n}\n"]} \ No newline at end of file diff --git a/lib/route-recognizer.js b/lib/route-recognizer.js index 187f34e..cf9f64f 100644 --- a/lib/route-recognizer.js +++ b/lib/route-recognizer.js @@ -1,323 +1,12 @@ -import map from './route-recognizer/dsl'; import Normalizer from './route-recognizer/normalizer'; +import RecognizeResults from './route-recognizer/recognize-results'; +import SegmentTrieNode from './route-recognizer/segment-trie-node'; +import { matcher } from './route-recognizer/segment-trie-node'; +import { bind, isArray } from './route-recognizer/polyfills'; var normalizePath = Normalizer.normalizePath; var normalizeSegment = Normalizer.normalizeSegment; -var specials = [ - '/', '.', '*', '+', '?', '|', - '(', ')', '[', ']', '{', '}', '\\' -]; - -var escapeRegex = new RegExp('(\\' + specials.join('|\\') + ')', 'g'); - -function isArray(test) { - return Object.prototype.toString.call(test) === "[object Array]"; -} - -// A Segment represents a segment in the original route description. -// Each Segment type provides an `eachChar` and `regex` method. -// -// The `eachChar` method invokes the callback with one or more character -// specifications. A character specification consumes one or more input -// characters. -// -// The `regex` method returns a regex fragment for the segment. If the -// segment is a dynamic of star segment, the regex fragment also includes -// a capture. -// -// A character specification contains: -// -// * `validChars`: a String with a list of all valid characters, or -// * `invalidChars`: a String with a list of all invalid characters -// * `repeat`: true if the character specification can repeat - -function StaticSegment(string) { this.string = normalizeSegment(string); } -StaticSegment.prototype = { - eachChar: function(currentState) { - var string = this.string, ch; - - for (var i=0; i x`. For instance, "199" is smaller - // then "200", even though "y" and "z" (which are both 9) are larger than "0" (the value - // of (`b` and `c`). This is because the leading symbol, "2", is larger than the other - // leading symbol, "1". - // The rule is that symbols to the left carry more weight than symbols to the right - // when a number is written out as a string. In the above strings, the leading digit - // represents how many 100's are in the number, and it carries more weight than the middle - // number which represents how many 10's are in the number. - // This system of number magnitude works well for route specificity, too. A route written as - // `a/b/c` will be more specific than `x/y/z` as long as `a` is more specific than - // `x`, irrespective of the other parts. - // Because of this similarity, we assign each type of segment a number value written as a - // string. We can find the specificity of compound routes by concatenating these strings - // together, from left to right. After we have looped through all of the segments, - // we convert the string to a number. - specificity.val = ''; - - for (var i=0; i= 0; i--) { + trieNode = segments[i]; - if (output.charAt(0) !== '/') { output = '/' + output; } + if (trieNode.type === 'static') { + if (trieNode.value === '') { continue; } + output += '/' + trieNode.value; + } else if (trieNode.type === 'param') { + if (this.ENCODE_AND_DECODE_PATH_SEGMENTS) { + output += '/' + encodeURIComponent(params[trieNode.value.substr(1)]); + } else { + output += '/' + params[trieNode.value.substr(1)]; + } + } else if (trieNode.type === 'glob') { + output += '/' + params[trieNode.value.substr(1)]; + } else { + output += '/' + params[trieNode.value.substr(1)]; + } + } if (params && params.queryParams) { - output += this.generateQueryString(params.queryParams, route.handlers); + output += this.generateQueryString(params.queryParams); } + // "/".charCodeAt(0) === 47 + if (output.charCodeAt(0) !== 47) { + output = '/' + output; + } return output; }, - generateQueryString: function(params, handlers) { + generateQueryString: function(params) { var pairs = []; var keys = []; for(var key in params) { @@ -461,6 +187,12 @@ RouteRecognizer.prototype = { return "?" + pairs.join("&"); }, + map: function(callback, addRouteCallback) { + this.compacted = false; + this.addRouteCallback = addRouteCallback; + callback(bind(matcher('map'), this.rootState)); + }, + parseQueryString: function(queryString) { var pairs = queryString.split("&"), queryParams = {}; for(var i=0; i < pairs.length; i++) { @@ -492,67 +224,75 @@ RouteRecognizer.prototype = { }, recognize: function(path) { - var states = [ this.rootState ], - pathLen, i, l, queryStart, queryParams = {}, - hashStart, - isSlashDropped = false; + if (!this.compacted) { this.rootState.compact(); this.compacted = true; } - hashStart = path.indexOf('#'); + var hashStart = path.indexOf('#'); if (hashStart !== -1) { path = path.substr(0, hashStart); } - queryStart = path.indexOf('?'); + var queryString, queryParams; + var queryStart = path.indexOf('?'); if (queryStart !== -1) { - var queryString = path.substr(queryStart + 1, path.length); + queryString = path.substr(queryStart + 1, path.length); path = path.substr(0, queryStart); queryParams = this.parseQueryString(queryString); } - if (path.charAt(0) !== "/") { path = "/" + path; } - var originalPath = path; + // "/".charCodeAt(0) === 47 + if (path.charCodeAt(0) === 47) { + path = path.substr(1); + } - if (RouteRecognizer.ENCODE_AND_DECODE_PATH_SEGMENTS) { + if (this.ENCODE_AND_DECODE_PATH_SEGMENTS) { path = normalizePath(path); } else { path = decodeURI(path); - originalPath = decodeURI(originalPath); } - pathLen = path.length; - if (pathLen > 1 && path.charAt(pathLen - 1) === "/") { - path = path.substr(0, pathLen - 1); - originalPath = originalPath.substr(0, pathLen - 1); - isSlashDropped = true; - } + var handlers = new RecognizeResults(queryParams); + var trieNode = this.rootState.walk(path, handlers, {}); - for (i=0; i 58 + case 42: this.type = 'glob'; break; // * => 42 + default: + this.type = 'static'; + normalized = normalizePath(this.value); + // We match against a normalized value. + // Keep the original value for error messaging. + if (normalized !== this.value) { + this.originalValue = this.value; + this.value = normalized; + } + break; + } + + this.handler = undefined; + this.childNodes = []; + this.parentNode = undefined; + } else { + this.id = value.id; + this.value = value.value; + this.originalValue = value.originalValue; + this.type = value.type; + this.handler = value.handler; + this.childNodes = value.childNodes || []; + this.parentNode = value.parentNode; + } +} + +SegmentTrieNode.prototype = { + + // Naively add a new child to the current trie node. + append: function(trieNode) { + this.childNodes.push(trieNode); + trieNode.parentNode = this; + return this; + }, + + // Reduce the amount of space which is needed to represent the + // radix trie by collapsing common prefixes. + compact: function() { + if (this.childNodes.length === 0) { return; } + + // Depth-first compaction. + this.childNodes.forEach(function(trieNode) { + trieNode.compact(); + }); + + // Collapse sibling nodes. + this.childNodes = this.childNodes.filter(function(trieNode, index, siblingNodes) { + var segmentSeen = false; + + // Scan only segments before this one to see if we've already got a match. + for (var i = 0; i < index && segmentSeen === false; i++) { + segmentSeen = ( + siblingNodes[i].value === trieNode.value && + siblingNodes[i].handler === trieNode.handler && + (trieNode.name === siblingNodes[i].name || trieNode.name === undefined || siblingNodes[i].name === undefined) + ); + } + + if (segmentSeen) { + var targetNode = siblingNodes[i-1]; + + // Reset the parentNode for each trieNode to the targetNode. + trieNode.childNodes.forEach(function(trieNode) { + trieNode.parentNode = targetNode; + }); + + targetNode.name = targetNode.name || trieNode.name; + + // Concat the childNodes of the active trieNode with the targetNode. + targetNode.childNodes = targetNode.childNodes.concat(trieNode.childNodes); + + // Then re-compact the joined trie. + targetNode.compact(); + } + + return !segmentSeen; + }); + + // Sort nodes to get an approximation of specificity. + this.childNodes.sort(function(a, b) { + var ascore, bscore; + switch (a.type) { + case "static": ascore = 0; break; + case "param": ascore = 1; break; + case "glob": ascore = 2; break; + } + switch (b.type) { + case "static": bscore = 0; break; + case "param": bscore = 1; break; + case "glob": bscore = 2; break; + } + + return ascore > bscore; + }); + }, + + // Can't just blindly return itself. + // Minimizes individual object size. + // Only called at build time. + toJSON: function() { + var childNodeCount = this.childNodes.length; + + var result = { + id: this.id, + type: this.type, + value: this.value, + handler: this.handler, + }; + + if (this.originalValue) { + result.originalValue = this.originalValue; + } + + if (this.handler) { + result.handler = this.handler; + } + + // Set up parentNode reference. + if (this.parentNode) { + result.parentNode = this.parentNode.id; + } + + // Set up childNodes references. + if (childNodeCount) { + result.childNodes = new Array(childNodeCount); + for (var i = 0; i < childNodeCount; i++) { + result.childNodes[i] = this.childNodes[i].id; + } + } + + return result; + }, + + /** + Binds a handler to this trie node. + If it receives a callback it will continue matching. + @public + */ + to: function(handler, callback, source) { + this.handler = handler; + + if (handler && this.router.addRouteCallback && source !== 'add') { + var routes = []; + var trieNode = this; + var current = { + path: '/' + trieNode.value, + handler: trieNode.handler + }; + + while (trieNode = trieNode.parentNode) { + if (trieNode.handler) { + if (current) { + routes.unshift(current); + current = { + path: '/' + trieNode.value, + handler: trieNode.handler + }; + } else { + current.path = trieNode.value === '' ? current.path : '/' + trieNode.value + current.path; + } + } else { + current.path = trieNode.value === '' ? current.path : '/' + trieNode.value + current.path; + } + } + + routes.unshift(current); + this.router.addRouteCallback(router, routes); + } + + if (callback) { + if (callback.length === 0) { throw new Error("You must have an argument in the function passed to `to`"); } + callback(bind(matcher(source), this)); + } + + return this; + }, + + /** + Our goal is to try and match based upon the node type. For non-globbing + routes we can simply pop a segment off of the path and continue, eliminating + entire branches as we go. Average number of comparisons is: + `Number of Trie Nodes / Average Depth / 2` + + If we reach a globbing route we have to change strategy and traverse to all + descendent leaf nodes until we find a match. As we traverse we build up a + regular expression that would match beginning with that globbing route. We + leverage the regular expression to handle the mechanics of greedy pattern + matching with back-tracing. The average number of comparisons beyond a + globbing route: + `Number of Trie Nodes / 2` + + This could be optimized further to do O(1) matching for non-globbing + segments but that is overkill for this use case. + */ + walk: function(path, handlers, params, regexPieces) { + var isLeafNode = (this.childNodes.length === 0); + var isTerminalNode = this.handler; + var nodeMatches = false; + var nextNode = this; + var consumed = false; + var segmentIndex = 0; + + // Identify the node type so we know how to match it. + switch (this.type) { + case "static": + if (regexPieces) { + // If we're descended from a globbing route. + regexPieces.push(this.value); + } else { + // Matches if the path to recognize is identical to the node value. + segmentIndex = this.value.length; + + if (segmentIndex === 0) { + // If the segment is an empty-length string. + nodeMatches = true; + } else { + // Get the first segment and compare. + nodeMatches = (path.split('/')[0] === this.value); + } + + if (nodeMatches) { + path = path.substring(segmentIndex); + + // "/".charCodeAt(0) === 47 + if (path.charCodeAt(0) === 47) { + path = path.substr(1); + } + } + } + break; + case "param": + if (regexPieces) { + // If we're descended from a globbing route. + regexPieces.push('([^/]+)'); + params[this.value.substring(1)] = { regex: true, index: regexPieces.length}; + } else { + // Valid for '/' to not appear, or appear anywhere but the 0th index. + // 0 length or 0th index would result in an non-matching empty param. + segmentIndex = path.indexOf('/'); + if (segmentIndex === -1) { segmentIndex = path.length; } + + nodeMatches = (path.length > 0 && segmentIndex > 0); + if (nodeMatches) { + if (this.router.ENCODE_AND_DECODE_PATH_SEGMENTS) { + params[this.value.substring(1)] = decodeURIComponent(path.substr(0, segmentIndex)); + } else { + params[this.value.substring(1)] = path.substr(0, segmentIndex); + } + path = path.substring(segmentIndex + 1); + } + } + break; + case "glob": + // We can no longer do prefix matching. Prepare to traverse leaves. + + // It's possible to have multiple globbing routes in a single path. + // So maybe we already have a `regexPieces` array. + if (!regexPieces) { regexPieces = []; } + + if (isLeafNode) { + // If a glob is the leaf node we don't match a trailing slash. + regexPieces.push('(.+)(?:/?)'); + } else { + // Consume the segment. `regexPieces.join` adds the '/'. + regexPieces.push('(.+)'); + } + params[this.value.substring(1)] = { regex: true, index: regexPieces.length}; + break; + } + + // Short-circuit for nodes that can't possibly match. + if (!nodeMatches && !regexPieces) { return false; } + + var isDynamic = (this.type !== "static"); + if (this.handler && !isDynamic) { + // Determine if maybe dynamic. + var current = this.parentNode; + while (current && !current.handler && !isDynamic) { + isDynamic = (current.type !== "static"); + current = current.parentNode; + } + } + + if (this.handler) { + handlers.push({ + handler: this.handler, + params: params, + isDynamic: isDynamic + }); + } + + var nextParams = this.handler ? {} : params; + + // Depth-first traversal of childNodes. No-op for leaf nodes. + for (var i = 0; i < this.childNodes.length; i++) { + nextNode = this.childNodes[i].walk(path, handlers, nextParams, regexPieces); + + // Stop traversing once we have a match since we're sorted by specificity. + if (!!nextNode) { break; } + } + + // If we're at a terminal node find out if we've consumed the entire path. + if (isTerminalNode) { + if (regexPieces) { + var myregex = new RegExp('^'+regexPieces.join('/')+'$'); + var matches = myregex.exec(path); + consumed = !!matches; + + if (consumed) { + // Need to move matches to the correct params location. + for (var j = 0; j < handlers.length; j++) { + for (var x in handlers[i].params) { + if (handlers[i].params[x].regex) { + handlers[i].params[x] = matches[handlers[i].params[x].index]; + } + } + } + } else { + // We pushed a segment onto the regexPieces, but this wasn't a match. + // Pop it back off for the next go-round. + regexPieces.pop(); + } + } else { + consumed = (path.length === 0); + } + } + + // `consumed` is false unless set above. + if (!consumed && (isLeafNode || !nextNode)) { + if (this.handler) { handlers.pop(); } + return false; + } else { + return nextNode; + } + }, + + wire: function() { + this.parentNode = this.router.nodes[this.parentNode]; + for (var i = 0; i < this.childNodes.length; i++) { + this.childNodes[i] = this.router.nodes[this.childNodes[i]]; + } + } +}; + +export { matcher }; +export default SegmentTrieNode; \ No newline at end of file diff --git a/tests/recognizer-tests.js b/tests/recognizer-tests.js index 2d38709..a45e81b 100644 --- a/tests/recognizer-tests.js +++ b/tests/recognizer-tests.js @@ -802,10 +802,10 @@ encodedCharGenerationExpectations.forEach(function(expectation) { equal(router.generate(route, params), expected); }); - test("When RouteRecognizer.ENCODE_AND_DECODE_PATH_SEGMENTS is false, does not encode dynamic segment for route '" + route + "' with params " + JSON.stringify(params), function() { - RouteRecognizer.ENCODE_AND_DECODE_PATH_SEGMENTS = false; + test("When router.ENCODE_AND_DECODE_PATH_SEGMENTS is false, does not encode dynamic segment for route '" + route + "' with params " + JSON.stringify(params), function() { + router.ENCODE_AND_DECODE_PATH_SEGMENTS = false; equal(router.generate(route, params), expectedUnencoded); - RouteRecognizer.ENCODE_AND_DECODE_PATH_SEGMENTS = true; + router.ENCODE_AND_DECODE_PATH_SEGMENTS = true; }); }); diff --git a/tests/router-tests.js b/tests/router-tests.js index 398d103..b4b7c27 100644 --- a/tests/router-tests.js +++ b/tests/router-tests.js @@ -2,7 +2,7 @@ import RouteRecognizer from 'route-recognizer'; -var router; +var router = {}; function resultsMatch(results, array, queryParams) { deepEqual(results.slice(), array); @@ -13,7 +13,12 @@ function resultsMatch(results, array, queryParams) { module("The match DSL", { setup: function() { - router = new RouteRecognizer(); + router.map = function() { + var original = new RouteRecognizer(); + original.map.apply(original, arguments); + var serialized = JSON.parse(JSON.stringify(original)); + router = new RouteRecognizer(serialized); + }; } });