import { RegExp, RegExpWrapper, RegExpMatcherWrapper, StringWrapper, isPresent, isBlank, BaseException } from 'angular2/src/facade/lang'; import { Map, MapWrapper, StringMap, StringMapWrapper, List, ListWrapper } from 'angular2/src/facade/collection'; import {IMPLEMENTS} from 'angular2/src/facade/lang'; import {RouteHandler} from './route_handler'; import {Url, RootUrl, serializeParams} from './url_parser'; import {ComponentInstruction} from './instruction'; export class TouchMap { map: StringMap = {}; keys: StringMap = {}; constructor(map: StringMap) { if (isPresent(map)) { StringMapWrapper.forEach(map, (value, key) => { this.map[key] = isPresent(value) ? value.toString() : null; this.keys[key] = true; }); } } get(key: string): string { StringMapWrapper.delete(this.keys, key); return this.map[key]; } getUnused(): StringMap { var unused: StringMap = StringMapWrapper.create(); var keys = StringMapWrapper.keys(this.keys); ListWrapper.forEach(keys, (key) => { unused[key] = StringMapWrapper.get(this.map, key); }); return unused; } } function normalizeString(obj: any): string { if (isBlank(obj)) { return null; } else { return obj.toString(); } } export interface Segment { name: string; generate(params: TouchMap): string; match(path: string): boolean; } class ContinuationSegment implements Segment { name: string = ''; generate(params: TouchMap): string { return ''; } match(path: string): boolean { return true; } } class StaticSegment implements Segment { name: string = ''; constructor(public path: string) {} match(path: string): boolean { return path == this.path; } generate(params: TouchMap): string { return this.path; } } class DynamicSegment implements Segment { constructor(public name: string) {} match(path: string): boolean { return true; } generate(params: TouchMap): string { if (!StringMapWrapper.contains(params.map, this.name)) { throw new BaseException( `Route generator for '${this.name}' was not included in parameters passed.`); } return normalizeString(params.get(this.name)); } } class StarSegment implements Segment { constructor(public name: string) {} match(path: string): boolean { return true; } generate(params: TouchMap): string { return normalizeString(params.get(this.name)); } } var paramMatcher = /^:([^\/]+)$/g; var wildcardMatcher = /^\*([^\/]+)$/g; function parsePathString(route: string): StringMap { // normalize route as not starting with a "/". Recognition will // also normalize. if (StringWrapper.startsWith(route, "/")) { route = StringWrapper.substring(route, 1); } var segments = splitBySlash(route); var results = []; var specificity = 0; // The "specificity" of a path is used to determine which route is used when multiple routes match // a URL. // Static segments (like "/foo") are the most specific, followed by dynamic segments (like // "/:id"). Star segments // add no specificity. Segments at the start of the path are more specific than proceeding ones. // The code below uses place values to combine the different types of segments into a single // integer that we can // sort later. Each static segment is worth hundreds of points of specificity (10000, 9900, ..., // 200), and each // dynamic segment is worth single points of specificity (100, 99, ... 2). if (segments.length > 98) { throw new BaseException(`'${route}' has more than the maximum supported number of segments.`); } var limit = segments.length - 1; for (var i = 0; i <= limit; i++) { var segment = segments[i], match; if (isPresent(match = RegExpWrapper.firstMatch(paramMatcher, segment))) { results.push(new DynamicSegment(match[1])); specificity += (100 - i); } else if (isPresent(match = RegExpWrapper.firstMatch(wildcardMatcher, segment))) { results.push(new StarSegment(match[1])); } else if (segment == '...') { if (i < limit) { // TODO (matsko): setup a proper error here ` throw new BaseException(`Unexpected "..." before the end of the path for "${route}".`); } results.push(new ContinuationSegment()); } else { results.push(new StaticSegment(segment)); specificity += 100 * (100 - i); } } var result = StringMapWrapper.create(); StringMapWrapper.set(result, 'segments', results); StringMapWrapper.set(result, 'specificity', specificity); return result; } // this function is used to determine whether a route config path like `/foo/:id` collides with // `/foo/:name` function pathDslHash(segments: List): string { return segments.map((segment) => { if (segment instanceof StarSegment) { return '*'; } else if (segment instanceof ContinuationSegment) { return '...'; } else if (segment instanceof DynamicSegment) { return ':'; } else if (segment instanceof StaticSegment) { return segment.path; } }) .join('/'); } function splitBySlash(url: string): List { return url.split('/'); } var RESERVED_CHARS = RegExpWrapper.create('//|\\(|\\)|;|\\?|='); function assertPath(path: string) { if (StringWrapper.contains(path, '#')) { throw new BaseException( `Path "${path}" should not include "#". Use "HashLocationStrategy" instead.`); } var illegalCharacter = RegExpWrapper.firstMatch(RESERVED_CHARS, path); if (isPresent(illegalCharacter)) { throw new BaseException( `Path "${path}" contains "${illegalCharacter[0]}" which is not allowed in a route config.`); } } export class PathMatch { constructor(public instruction: ComponentInstruction, public remaining: Url, public remainingAux: List) {} } // represents something like '/foo/:bar' export class PathRecognizer { private _segments: List; specificity: number; terminal: boolean = true; hash: string; // TODO: cache component instruction instances by params and by ParsedUrl instance constructor(public path: string, public handler: RouteHandler) { assertPath(path); var parsed = parsePathString(path); this._segments = parsed['segments']; this.specificity = parsed['specificity']; this.hash = pathDslHash(this._segments); var lastSegment = this._segments[this._segments.length - 1]; this.terminal = !(lastSegment instanceof ContinuationSegment); } recognize(beginningSegment: Url): PathMatch { var nextSegment = beginningSegment; var currentSegment: Url; var positionalParams = {}; var captured = []; for (var i = 0; i < this._segments.length; i += 1) { if (isBlank(nextSegment)) { return null; } currentSegment = nextSegment; var segment = this._segments[i]; if (segment instanceof ContinuationSegment) { break; } captured.push(currentSegment.path); // the star segment consumes all of the remaining URL, including matrix params if (segment instanceof StarSegment) { positionalParams[segment.name] = currentSegment.toString(); nextSegment = null; break; } if (segment instanceof DynamicSegment) { positionalParams[segment.name] = currentSegment.path; } else if (!segment.match(currentSegment.path)) { return null; } nextSegment = currentSegment.child; } if (this.terminal && isPresent(nextSegment)) { return null; } var urlPath = captured.join('/'); // If this is the root component, read query params. Otherwise, read matrix params. var paramsSegment = beginningSegment instanceof RootUrl ? beginningSegment : currentSegment; var allParams = isPresent(paramsSegment.params) ? StringMapWrapper.merge(paramsSegment.params, positionalParams) : positionalParams; var urlParams = serializeParams(paramsSegment.params); var instruction = new ComponentInstruction(urlPath, urlParams, this, allParams); return new PathMatch(instruction, nextSegment, currentSegment.auxiliary); } generate(params: StringMap): ComponentInstruction { var paramTokens = new TouchMap(params); var path = []; for (var i = 0; i < this._segments.length; i++) { let segment = this._segments[i]; if (!(segment instanceof ContinuationSegment)) { path.push(segment.generate(paramTokens)); } } var urlPath = path.join('/'); var nonPositionalParams = paramTokens.getUnused(); var urlParams = serializeParams(nonPositionalParams); return new ComponentInstruction(urlPath, urlParams, this, params); } }