From 86aeb8be0a0b03aa8266c1ca1426f9a58766e234 Mon Sep 17 00:00:00 2001 From: Tobias Bosch Date: Wed, 7 Oct 2015 09:34:21 -0700 Subject: [PATCH] feat(Compiler): case sensitive html parser --- modules/angular2/src/compiler/html_ast.ts | 10 +- modules/angular2/src/compiler/html_lexer.ts | 478 +++++++++++++++ modules/angular2/src/compiler/html_parser.ts | 306 +++++++--- modules/angular2/src/compiler/html_tags.ts | 69 +++ modules/angular2/src/compiler/parse_util.ts | 31 + modules/angular2/src/compiler/template_ast.ts | 28 +- .../src/compiler/template_normalizer.ts | 11 +- .../angular2/src/compiler/template_parser.ts | 207 ++++--- .../test/compiler/command_compiler_spec.ts | 2 +- .../angular2/test/compiler/html_lexer_spec.ts | 523 ++++++++++++++++ .../test/compiler/html_parser_spec.ts | 96 ++- .../test/compiler/template_parser_spec.ts | 570 ++++++------------ .../test/compiler/template_preparser_spec.ts | 2 +- .../test/core/linker/integration_spec.ts | 11 +- 14 files changed, 1674 insertions(+), 670 deletions(-) create mode 100644 modules/angular2/src/compiler/html_lexer.ts create mode 100644 modules/angular2/src/compiler/html_tags.ts create mode 100644 modules/angular2/src/compiler/parse_util.ts create mode 100644 modules/angular2/test/compiler/html_lexer_spec.ts diff --git a/modules/angular2/src/compiler/html_ast.ts b/modules/angular2/src/compiler/html_ast.ts index 6f93932c31..974e53e1c7 100644 --- a/modules/angular2/src/compiler/html_ast.ts +++ b/modules/angular2/src/compiler/html_ast.ts @@ -1,23 +1,25 @@ import {isPresent} from 'angular2/src/facade/lang'; +import {ParseSourceSpan} from './parse_util'; + export interface HtmlAst { - sourceInfo: string; + sourceSpan: ParseSourceSpan; visit(visitor: HtmlAstVisitor, context: any): any; } export class HtmlTextAst implements HtmlAst { - constructor(public value: string, public sourceInfo: string) {} + constructor(public value: string, public sourceSpan: ParseSourceSpan) {} visit(visitor: HtmlAstVisitor, context: any): any { return visitor.visitText(this, context); } } export class HtmlAttrAst implements HtmlAst { - constructor(public name: string, public value: string, public sourceInfo: string) {} + constructor(public name: string, public value: string, public sourceSpan: ParseSourceSpan) {} visit(visitor: HtmlAstVisitor, context: any): any { return visitor.visitAttr(this, context); } } export class HtmlElementAst implements HtmlAst { constructor(public name: string, public attrs: HtmlAttrAst[], public children: HtmlAst[], - public sourceInfo: string) {} + public sourceSpan: ParseSourceSpan) {} visit(visitor: HtmlAstVisitor, context: any): any { return visitor.visitElement(this, context); } } diff --git a/modules/angular2/src/compiler/html_lexer.ts b/modules/angular2/src/compiler/html_lexer.ts new file mode 100644 index 0000000000..5b56a9b94d --- /dev/null +++ b/modules/angular2/src/compiler/html_lexer.ts @@ -0,0 +1,478 @@ +import { + StringWrapper, + NumberWrapper, + isPresent, + isBlank, + CONST_EXPR, + serializeEnum +} from 'angular2/src/facade/lang'; +import {BaseException} from 'angular2/src/facade/exceptions'; +import {ParseLocation, ParseError, ParseSourceFile, ParseSourceSpan} from './parse_util'; +import {getHtmlTagDefinition, HtmlTagContentType, NAMED_ENTITIES} from './html_tags'; + +export enum HtmlTokenType { + TAG_OPEN_START, + TAG_OPEN_END, + TAG_OPEN_END_VOID, + TAG_CLOSE, + TEXT, + ESCAPABLE_RAW_TEXT, + RAW_TEXT, + COMMENT_START, + COMMENT_END, + CDATA_START, + CDATA_END, + ATTR_NAME, + ATTR_VALUE, + DOC_TYPE, + EOF +} + +export class HtmlToken { + constructor(public type: HtmlTokenType, public parts: string[], + public sourceSpan: ParseSourceSpan) {} +} + +export class HtmlTokenError extends ParseError { + constructor(errorMsg: string, public tokenType: HtmlTokenType, location: ParseLocation) { + super(location, errorMsg); + } +} + +export class HtmlTokenizeResult { + constructor(public tokens: HtmlToken[], public errors: HtmlTokenError[]) {} +} + +export function tokenizeHtml(sourceContent: string, sourceUrl: string): HtmlTokenizeResult { + return new _HtmlTokenizer(new ParseSourceFile(sourceContent, sourceUrl)).tokenize(); +} + +const $EOF = 0; +const $TAB = 9; +const $LF = 10; +const $CR = 13; + +const $SPACE = 32; + +const $BANG = 33; +const $DQ = 34; +const $$ = 36; +const $AMPERSAND = 38; +const $SQ = 39; +const $MINUS = 45; +const $SLASH = 47; +const $0 = 48; + +const $SEMICOLON = 59; + +const $9 = 57; +const $COLON = 58; +const $LT = 60; +const $EQ = 61; +const $GT = 62; +const $QUESTION = 63; +const $A = 65; +const $Z = 90; +const $LBRACKET = 91; +const $RBRACKET = 93; +const $a = 97; +const $z = 122; + +const $NBSP = 160; + +function unexpectedCharacterErrorMsg(charCode: number): string { + var char = charCode === $EOF ? 'EOF' : StringWrapper.fromCharCode(charCode); + return `Unexpected character "${char}"`; +} + +function unknownEntityErrorMsg(entitySrc: string): string { + return `Unknown entity "${entitySrc}"`; +} + +class ControlFlowError { + constructor(public error: HtmlTokenError) {} +} + +// See http://www.w3.org/TR/html51/syntax.html#writing +class _HtmlTokenizer { + private input: string; + private inputLowercase: string; + private length: number; + // Note: this is always lowercase! + private peek: number = -1; + private index: number = -1; + private line: number = 0; + private column: number = -1; + private currentTokenStart: ParseLocation; + private currentTokenType: HtmlTokenType; + + tokens: HtmlToken[] = []; + errors: HtmlTokenError[] = []; + + constructor(private file: ParseSourceFile) { + this.input = file.content; + this.inputLowercase = file.content.toLowerCase(); + this.length = file.content.length; + this._advance(); + } + + tokenize(): HtmlTokenizeResult { + while (this.peek !== $EOF) { + var start = this._getLocation(); + try { + if (this._attemptChar($LT)) { + if (this._attemptChar($BANG)) { + if (this._attemptChar($LBRACKET)) { + this._consumeCdata(start); + } else if (this._attemptChar($MINUS)) { + this._consumeComment(start); + } else { + this._consumeDocType(start); + } + } else if (this._attemptChar($SLASH)) { + this._consumeTagClose(start); + } else { + this._consumeTagOpen(start); + } + } else { + this._consumeText(); + } + } catch (e) { + if (e instanceof ControlFlowError) { + this.errors.push(e.error); + } else { + throw e; + } + } + } + this._beginToken(HtmlTokenType.EOF); + this._endToken([]); + return new HtmlTokenizeResult(this.tokens, this.errors); + } + + private _getLocation(): ParseLocation { + return new ParseLocation(this.file, this.index, this.line, this.column); + } + + private _beginToken(type: HtmlTokenType, start: ParseLocation = null) { + if (isBlank(start)) { + start = this._getLocation(); + } + this.currentTokenStart = start; + this.currentTokenType = type; + } + + private _endToken(parts: string[], end: ParseLocation = null): HtmlToken { + if (isBlank(end)) { + end = this._getLocation(); + } + var token = new HtmlToken(this.currentTokenType, parts, + new ParseSourceSpan(this.currentTokenStart, end)); + this.tokens.push(token); + this.currentTokenStart = null; + this.currentTokenType = null; + return token; + } + + private _createError(msg: string, position: ParseLocation): ControlFlowError { + var error = new HtmlTokenError(msg, this.currentTokenType, position); + this.currentTokenStart = null; + this.currentTokenType = null; + return new ControlFlowError(error); + } + + private _advance() { + if (this.index >= this.length) { + throw this._createError(unexpectedCharacterErrorMsg($EOF), this._getLocation()); + } + if (this.peek === $LF) { + this.line++; + this.column = 0; + } else if (this.peek !== $LF && this.peek !== $CR) { + this.column++; + } + this.index++; + this.peek = this.index >= this.length ? $EOF : StringWrapper.charCodeAt(this.inputLowercase, + this.index); + } + + private _attemptChar(charCode: number): boolean { + if (this.peek === charCode) { + this._advance(); + return true; + } + return false; + } + + private _requireChar(charCode: number) { + var location = this._getLocation(); + if (!this._attemptChar(charCode)) { + throw this._createError(unexpectedCharacterErrorMsg(this.peek), location); + } + } + + private _attemptChars(chars: string): boolean { + for (var i = 0; i < chars.length; i++) { + if (!this._attemptChar(StringWrapper.charCodeAt(chars, i))) { + return false; + } + } + return true; + } + + private _requireChars(chars: string) { + var location = this._getLocation(); + if (!this._attemptChars(chars)) { + throw this._createError(unexpectedCharacterErrorMsg(this.peek), location); + } + } + + private _attemptUntilFn(predicate: Function) { + while (!predicate(this.peek)) { + this._advance(); + } + } + + private _requireUntilFn(predicate: Function, len: number) { + var start = this._getLocation(); + this._attemptUntilFn(predicate); + if (this.index - start.offset < len) { + throw this._createError(unexpectedCharacterErrorMsg(this.peek), start); + } + } + + private _attemptUntilChar(char: number) { + while (this.peek !== char) { + this._advance(); + } + } + + private _readChar(decodeEntities: boolean): string { + if (decodeEntities && this.peek === $AMPERSAND) { + var start = this._getLocation(); + this._attemptUntilChar($SEMICOLON); + this._advance(); + var entitySrc = this.input.substring(start.offset + 1, this.index - 1); + var decodedEntity = decodeEntity(entitySrc); + if (isPresent(decodedEntity)) { + return decodedEntity; + } else { + throw this._createError(unknownEntityErrorMsg(entitySrc), start); + } + } else { + var index = this.index; + this._advance(); + return this.input[index]; + } + } + + private _consumeRawText(decodeEntities: boolean, firstCharOfEnd: number, + attemptEndRest: Function): HtmlToken { + var tagCloseStart; + var textStart = this._getLocation(); + this._beginToken(decodeEntities ? HtmlTokenType.ESCAPABLE_RAW_TEXT : HtmlTokenType.RAW_TEXT, + textStart); + var parts = []; + while (true) { + tagCloseStart = this._getLocation(); + if (this._attemptChar(firstCharOfEnd) && attemptEndRest()) { + break; + } + if (this.index > tagCloseStart.offset) { + parts.push(this.input.substring(tagCloseStart.offset, this.index)); + } + while (this.peek !== firstCharOfEnd) { + parts.push(this._readChar(decodeEntities)); + } + } + return this._endToken([parts.join('')], tagCloseStart); + } + + private _consumeComment(start: ParseLocation) { + this._beginToken(HtmlTokenType.COMMENT_START, start); + this._requireChar($MINUS); + this._endToken([]); + var textToken = this._consumeRawText(false, $MINUS, () => this._attemptChars('->')); + this._beginToken(HtmlTokenType.COMMENT_END, textToken.sourceSpan.end); + this._endToken([]); + } + + private _consumeCdata(start: ParseLocation) { + this._beginToken(HtmlTokenType.CDATA_START, start); + this._requireChars('cdata['); + this._endToken([]); + var textToken = this._consumeRawText(false, $RBRACKET, () => this._attemptChars(']>')); + this._beginToken(HtmlTokenType.CDATA_END, textToken.sourceSpan.end); + this._endToken([]); + } + + private _consumeDocType(start: ParseLocation) { + this._beginToken(HtmlTokenType.DOC_TYPE, start); + this._attemptUntilChar($GT); + this._advance(); + this._endToken([this.input.substring(start.offset + 2, this.index - 1)]); + } + + private _consumePrefixAndName(): string[] { + var nameOrPrefixStart = this.index; + var prefix = null; + while (this.peek !== $COLON && !isPrefixEnd(this.peek)) { + this._advance(); + } + var nameStart; + if (this.peek === $COLON) { + this._advance(); + prefix = this.input.substring(nameOrPrefixStart, this.index - 1); + nameStart = this.index; + } else { + nameStart = nameOrPrefixStart; + } + this._requireUntilFn(isNameEnd, this.index === nameStart ? 1 : 0); + var name = this.input.substring(nameStart, this.index); + return [prefix, name]; + } + + private _consumeTagOpen(start: ParseLocation) { + this._attemptUntilFn(isNotWhitespace); + var nameStart = this.index; + this._consumeTagOpenStart(start); + var lowercaseTagName = this.inputLowercase.substring(nameStart, this.index); + this._attemptUntilFn(isNotWhitespace); + while (this.peek !== $SLASH && this.peek !== $GT) { + this._consumeAttributeName(); + this._attemptUntilFn(isNotWhitespace); + if (this._attemptChar($EQ)) { + this._attemptUntilFn(isNotWhitespace); + this._consumeAttributeValue(); + } + this._attemptUntilFn(isNotWhitespace); + } + this._consumeTagOpenEnd(); + var contentTokenType = getHtmlTagDefinition(lowercaseTagName).contentType; + if (contentTokenType === HtmlTagContentType.RAW_TEXT) { + this._consumeRawTextWithTagClose(lowercaseTagName, false); + } else if (contentTokenType === HtmlTagContentType.ESCAPABLE_RAW_TEXT) { + this._consumeRawTextWithTagClose(lowercaseTagName, true); + } + } + + private _consumeRawTextWithTagClose(lowercaseTagName: string, decodeEntities: boolean) { + var textToken = this._consumeRawText(decodeEntities, $LT, () => { + if (!this._attemptChar($SLASH)) return false; + this._attemptUntilFn(isNotWhitespace); + if (!this._attemptChars(lowercaseTagName)) return false; + this._attemptUntilFn(isNotWhitespace); + if (!this._attemptChar($GT)) return false; + return true; + }); + this._beginToken(HtmlTokenType.TAG_CLOSE, textToken.sourceSpan.end); + this._endToken([null, lowercaseTagName]); + } + + private _consumeTagOpenStart(start: ParseLocation) { + this._beginToken(HtmlTokenType.TAG_OPEN_START, start); + var parts = this._consumePrefixAndName(); + this._endToken(parts); + } + + private _consumeAttributeName() { + this._beginToken(HtmlTokenType.ATTR_NAME); + var prefixAndName = this._consumePrefixAndName(); + this._endToken(prefixAndName); + } + + private _consumeAttributeValue() { + this._beginToken(HtmlTokenType.ATTR_VALUE); + var value; + if (this.peek === $SQ || this.peek === $DQ) { + var quoteChar = this.peek; + this._advance(); + var parts = []; + while (this.peek !== quoteChar) { + parts.push(this._readChar(true)); + } + value = parts.join(''); + this._advance(); + } else { + var valueStart = this.index; + this._requireUntilFn(isNameEnd, 1); + value = this.input.substring(valueStart, this.index); + } + this._endToken([value]); + } + + private _consumeTagOpenEnd() { + var tokenType = + this._attemptChar($SLASH) ? HtmlTokenType.TAG_OPEN_END_VOID : HtmlTokenType.TAG_OPEN_END; + this._beginToken(tokenType); + this._requireChar($GT); + this._endToken([]); + } + + private _consumeTagClose(start: ParseLocation) { + this._beginToken(HtmlTokenType.TAG_CLOSE, start); + this._attemptUntilFn(isNotWhitespace); + var prefixAndName; + prefixAndName = this._consumePrefixAndName(); + this._attemptUntilFn(isNotWhitespace); + this._requireChar($GT); + this._endToken(prefixAndName); + } + + private _consumeText() { + var start = this._getLocation(); + this._beginToken(HtmlTokenType.TEXT, start); + var parts = [this._readChar(true)]; + while (!isTextEnd(this.peek)) { + parts.push(this._readChar(true)); + } + this._endToken([parts.join('')]); + } +} + +function isNotWhitespace(code: number): boolean { + return !isWhitespace(code) || code === $EOF; +} + +function isWhitespace(code: number): boolean { + return (code >= $TAB && code <= $SPACE) || (code === $NBSP); +} + +function isNameEnd(code: number): boolean { + return isWhitespace(code) || code === $GT || code === $SLASH || code === $SQ || code === $DQ || + code === $EQ +} + +function isPrefixEnd(code: number): boolean { + return (code < $a || $z < code) && (code < $A || $Z < code) && (code < $0 || code > $9); +} + +function isTextEnd(code: number): boolean { + return code === $LT || code === $EOF; +} + +function decodeEntity(entity: string): string { + var i = 0; + var isNumber = entity.length > i && entity[i] == '#'; + if (isNumber) i++; + var isHex = entity.length > i && entity[i] == 'x'; + if (isHex) i++; + var value = entity.substring(i); + var result = null; + if (isNumber) { + var charCode; + try { + charCode = NumberWrapper.parseInt(value, isHex ? 16 : 10); + } catch (e) { + return null; + } + result = StringWrapper.fromCharCode(charCode); + } else { + result = NAMED_ENTITIES[value]; + } + if (isPresent(result)) { + return result; + } else { + return null; + } +} diff --git a/modules/angular2/src/compiler/html_parser.ts b/modules/angular2/src/compiler/html_parser.ts index 3e30b790e9..ea303e71da 100644 --- a/modules/angular2/src/compiler/html_parser.ts +++ b/modules/angular2/src/compiler/html_parser.ts @@ -1,118 +1,248 @@ import { isPresent, + isBlank, StringWrapper, stringify, assertionsEnabled, - StringJoiner + StringJoiner, + RegExpWrapper, + serializeEnum, + CONST_EXPR } from 'angular2/src/facade/lang'; import {DOM} from 'angular2/src/core/dom/dom_adapter'; +import {ListWrapper} from 'angular2/src/facade/collection'; -import { - HtmlAst, - HtmlAttrAst, - HtmlTextAst, - HtmlElementAst, - HtmlAstVisitor, - htmlVisitAll -} from './html_ast'; +import {HtmlAst, HtmlAttrAst, HtmlTextAst, HtmlElementAst} from './html_ast'; import {escapeDoubleQuoteString} from './util'; import {Injectable} from 'angular2/src/core/di'; +import {HtmlToken, HtmlTokenType, tokenizeHtml} from './html_lexer'; +import {ParseError, ParseLocation, ParseSourceSpan} from './parse_util'; +import {HtmlTagDefinition, getHtmlTagDefinition} from './html_tags'; + +// TODO: remove this, just provide a plain error message! +export enum HtmlTreeErrorType { + UnexpectedClosingTag +} + +const HTML_ERROR_TYPE_MSGS = CONST_EXPR(['Unexpected closing tag']); + + +export class HtmlTreeError extends ParseError { + static create(type: HtmlTreeErrorType, elementName: string, + location: ParseLocation): HtmlTreeError { + return new HtmlTreeError(type, HTML_ERROR_TYPE_MSGS[serializeEnum(type)], elementName, + location); + } + + constructor(public type: HtmlTreeErrorType, msg: string, public elementName: string, + location: ParseLocation) { + super(location, msg); + } +} + +export class HtmlParseTreeResult { + constructor(public rootNodes: HtmlAst[], public errors: ParseError[]) {} +} @Injectable() export class HtmlParser { - parse(template: string, sourceInfo: string): HtmlAst[] { - var root = DOM.createTemplate(template); - return parseChildNodes(root, sourceInfo); - } - unparse(nodes: HtmlAst[]): string { - var visitor = new UnparseVisitor(); - var parts = []; - htmlVisitAll(visitor, nodes, parts); - return parts.join(''); + parse(sourceContent: string, sourceUrl: string): HtmlParseTreeResult { + var tokensAndErrors = tokenizeHtml(sourceContent, sourceUrl); + var treeAndErrors = new TreeBuilder(tokensAndErrors.tokens).build(); + return new HtmlParseTreeResult(treeAndErrors.rootNodes, (tokensAndErrors.errors) + .concat(treeAndErrors.errors)); } } -function parseText(text: Text, indexInParent: number, parentSourceInfo: string): HtmlTextAst { - // TODO(tbosch): add source row/column source info from parse5 / package:html - var value = DOM.getText(text); - return new HtmlTextAst(value, - `${parentSourceInfo} > #text(${value}):nth-child(${indexInParent})`); -} +var NS_PREFIX_RE = /^@[^:]+/g; -function parseAttr(element: Element, parentSourceInfo: string, attrName: string, - attrValue: string): HtmlAttrAst { - // TODO(tbosch): add source row/column source info from parse5 / package:html - return new HtmlAttrAst(attrName, attrValue, `${parentSourceInfo}[${attrName}=${attrValue}]`); -} +class TreeBuilder { + private index: number = -1; + private length: number; + private peek: HtmlToken; -function parseElement(element: Element, indexInParent: number, - parentSourceInfo: string): HtmlElementAst { - // normalize nodename always as lower case so that following build steps - // can rely on this - var nodeName = DOM.nodeName(element).toLowerCase(); - // TODO(tbosch): add source row/column source info from parse5 / package:html - var sourceInfo = `${parentSourceInfo} > ${nodeName}:nth-child(${indexInParent})`; - var attrs = parseAttrs(element, sourceInfo); + private rootNodes: HtmlAst[] = []; + private errors: HtmlTreeError[] = []; - var childNodes = parseChildNodes(element, sourceInfo); - return new HtmlElementAst(nodeName, attrs, childNodes, sourceInfo); -} + private elementStack: HtmlElementAst[] = []; -function parseAttrs(element: Element, elementSourceInfo: string): HtmlAttrAst[] { - // Note: sort the attributes early in the pipeline to get - // consistent results throughout the pipeline, as attribute order is not defined - // in DOM parsers! - var attrMap = DOM.attributeMap(element); - var attrList: string[][] = []; - attrMap.forEach((value, name) => attrList.push([name, value])); - attrList.sort((entry1, entry2) => StringWrapper.compare(entry1[0], entry2[0])); - return attrList.map(entry => parseAttr(element, elementSourceInfo, entry[0], entry[1])); -} + constructor(private tokens: HtmlToken[]) { this._advance(); } -function parseChildNodes(element: Element, parentSourceInfo: string): HtmlAst[] { - var root = DOM.templateAwareRoot(element); - var childNodes = DOM.childNodesAsList(root); - var result = []; - var index = 0; - childNodes.forEach(childNode => { - var childResult = null; - if (DOM.isTextNode(childNode)) { - var text = childNode; - childResult = parseText(text, index, parentSourceInfo); - } else if (DOM.isElementNode(childNode)) { - var el = childNode; - childResult = parseElement(el, index, parentSourceInfo); + build(): HtmlParseTreeResult { + while (this.peek.type !== HtmlTokenType.EOF) { + if (this.peek.type === HtmlTokenType.TAG_OPEN_START) { + this._consumeStartTag(this._advance()); + } else if (this.peek.type === HtmlTokenType.TAG_CLOSE) { + this._consumeEndTag(this._advance()); + } else if (this.peek.type === HtmlTokenType.CDATA_START) { + this._consumeCdata(this._advance()); + } else if (this.peek.type === HtmlTokenType.COMMENT_START) { + this._consumeComment(this._advance()); + } else if (this.peek.type === HtmlTokenType.TEXT || + this.peek.type === HtmlTokenType.RAW_TEXT || + this.peek.type === HtmlTokenType.ESCAPABLE_RAW_TEXT) { + this._consumeText(this._advance()); + } else { + // Skip all other tokens... + this._advance(); + } } - if (isPresent(childResult)) { - // Won't have a childResult for e.g. comment nodes - result.push(childResult); - } - index++; - }); - return result; -} + return new HtmlParseTreeResult(this.rootNodes, this.errors); + } -class UnparseVisitor implements HtmlAstVisitor { - visitElement(ast: HtmlElementAst, parts: string[]): any { - parts.push(`<${ast.name}`); + private _advance(): HtmlToken { + var prev = this.peek; + if (this.index < this.tokens.length - 1) { + // Note: there is always an EOF token at the end + this.index++; + } + this.peek = this.tokens[this.index]; + return prev; + } + + private _advanceIf(type: HtmlTokenType): HtmlToken { + if (this.peek.type === type) { + return this._advance(); + } + return null; + } + + private _consumeCdata(startToken: HtmlToken) { + this._consumeText(this._advance()); + this._advanceIf(HtmlTokenType.CDATA_END); + } + + private _consumeComment(startToken: HtmlToken) { + this._advanceIf(HtmlTokenType.RAW_TEXT); + this._advanceIf(HtmlTokenType.COMMENT_END); + } + + private _consumeText(token: HtmlToken) { + this._addToParent(new HtmlTextAst(token.parts[0], token.sourceSpan)); + } + + private _consumeStartTag(startTagToken: HtmlToken) { + var prefix = startTagToken.parts[0]; + var name = startTagToken.parts[1]; var attrs = []; - htmlVisitAll(this, ast.attrs, attrs); - if (ast.attrs.length > 0) { - parts.push(' '); - parts.push(attrs.join(' ')); + while (this.peek.type === HtmlTokenType.ATTR_NAME) { + attrs.push(this._consumeAttr(this._advance())); + } + var fullName = elementName(prefix, name, this._getParentElement()); + var voidElement = false; + // Note: There could have been a tokenizer error + // so that we don't get a token for the end tag... + if (this.peek.type === HtmlTokenType.TAG_OPEN_END_VOID) { + this._advance(); + voidElement = true; + } else if (this.peek.type === HtmlTokenType.TAG_OPEN_END) { + this._advance(); + voidElement = false; + } + var end = this.peek.sourceSpan.start; + var el = new HtmlElementAst(fullName, attrs, [], + new ParseSourceSpan(startTagToken.sourceSpan.start, end)); + this._pushElement(el); + if (voidElement) { + this._popElement(fullName); } - parts.push(`>`); - htmlVisitAll(this, ast.children, parts); - parts.push(``); - return null; } - visitAttr(ast: HtmlAttrAst, parts: string[]): any { - parts.push(`${ast.name}=${escapeDoubleQuoteString(ast.value)}`); - return null; + + private _pushElement(el: HtmlElementAst) { + var stackIndex = this.elementStack.length - 1; + while (stackIndex >= 0) { + var parentEl = this.elementStack[stackIndex]; + if (!getHtmlTagDefinition(parentEl.name).isClosedByChild(el.name)) { + break; + } + stackIndex--; + } + this.elementStack.splice(stackIndex, this.elementStack.length - 1 - stackIndex); + + var tagDef = getHtmlTagDefinition(el.name); + var parentEl = this._getParentElement(); + if (tagDef.requireExtraParent(isPresent(parentEl) ? parentEl.name : null)) { + var newParent = new HtmlElementAst(tagDef.requiredParent, [], [el], el.sourceSpan); + this._addToParent(newParent); + this.elementStack.push(newParent); + this.elementStack.push(el); + } else { + this._addToParent(el); + this.elementStack.push(el); + } } - visitText(ast: HtmlTextAst, parts: string[]): any { - parts.push(ast.value); - return null; + + private _consumeEndTag(endTagToken: HtmlToken) { + var fullName = + elementName(endTagToken.parts[0], endTagToken.parts[1], this._getParentElement()); + if (!this._popElement(fullName)) { + this.errors.push(HtmlTreeError.create(HtmlTreeErrorType.UnexpectedClosingTag, fullName, + endTagToken.sourceSpan.start)); + } + } + + private _popElement(fullName: string): boolean { + var stackIndex = this.elementStack.length - 1; + var hasError = false; + while (stackIndex >= 0) { + var el = this.elementStack[stackIndex]; + if (el.name == fullName) { + break; + } + if (!getHtmlTagDefinition(el.name).closedByParent) { + hasError = true; + break; + } + stackIndex--; + } + if (!hasError) { + this.elementStack.splice(stackIndex, this.elementStack.length - stackIndex); + } + return !hasError; + } + + private _consumeAttr(attrName: HtmlToken): HtmlAttrAst { + var fullName = elementName(attrName.parts[0], attrName.parts[1], null); + var end = attrName.sourceSpan.end; + var value = ''; + if (this.peek.type === HtmlTokenType.ATTR_VALUE) { + var valueToken = this._advance(); + value = valueToken.parts[0]; + end = valueToken.sourceSpan.end; + } + return new HtmlAttrAst(fullName, value, new ParseSourceSpan(attrName.sourceSpan.start, end)); + } + + private _getParentElement(): HtmlElementAst { + return this.elementStack.length > 0 ? ListWrapper.last(this.elementStack) : null; + } + + private _addToParent(node: HtmlAst) { + var parent = this._getParentElement(); + if (isPresent(parent)) { + parent.children.push(node); + } else { + this.rootNodes.push(node); + } } } + +function elementName(prefix: string, localName: string, parentElement: HtmlElementAst) { + if (isBlank(prefix)) { + prefix = getHtmlTagDefinition(localName).implicitNamespacePrefix; + } + if (isBlank(prefix) && isPresent(parentElement)) { + prefix = namespacePrefix(parentElement.name); + } + if (isPresent(prefix)) { + return `@${prefix}:${localName}`; + } else { + return localName; + } +} + +function namespacePrefix(elementName: string): string { + var match = RegExpWrapper.firstMatch(NS_PREFIX_RE, elementName); + return isBlank(match) ? null : match[1]; +} diff --git a/modules/angular2/src/compiler/html_tags.ts b/modules/angular2/src/compiler/html_tags.ts new file mode 100644 index 0000000000..a88bc363e4 --- /dev/null +++ b/modules/angular2/src/compiler/html_tags.ts @@ -0,0 +1,69 @@ +import {isPresent, isBlank, normalizeBool, CONST_EXPR} from 'angular2/src/facade/lang'; + +// TODO: fill this! +export const NAMED_ENTITIES: {[key: string]: string} = CONST_EXPR({'amp': '&'}); + +export enum HtmlTagContentType { + RAW_TEXT, + ESCAPABLE_RAW_TEXT, + PARSABLE_DATA +} + +export class HtmlTagDefinition { + private closedByChildren: {[key: string]: boolean} = {}; + public closedByParent: boolean; + public requiredParent: string; + public implicitNamespacePrefix: string; + public contentType: HtmlTagContentType; + + constructor({closedByChildren, requiredParent, implicitNamespacePrefix, contentType}: { + closedByChildren?: string[], + requiredParent?: string, + implicitNamespacePrefix?: string, + contentType?: HtmlTagContentType + } = {}) { + if (isPresent(closedByChildren)) { + closedByChildren.forEach(tagName => this.closedByChildren[tagName] = true); + } + this.closedByParent = isPresent(closedByChildren) && closedByChildren.length > 0; + this.requiredParent = requiredParent; + this.implicitNamespacePrefix = implicitNamespacePrefix; + this.contentType = isPresent(contentType) ? contentType : HtmlTagContentType.PARSABLE_DATA; + } + + requireExtraParent(currentParent: string) { + return isPresent(this.requiredParent) && + (isBlank(currentParent) || this.requiredParent != currentParent.toLocaleLowerCase()); + } + + isClosedByChild(name: string) { + return normalizeBool(this.closedByChildren['*']) || + normalizeBool(this.closedByChildren[name.toLowerCase()]); + } +} + +// TODO: Fill this table using +// https://github.com/greim/html-tokenizer/blob/master/parser.js +// and http://www.w3.org/TR/html51/syntax.html#optional-tags +var TAG_DEFINITIONS: {[key: string]: HtmlTagDefinition} = { + 'link': new HtmlTagDefinition({closedByChildren: ['*']}), + 'ng-content': new HtmlTagDefinition({closedByChildren: ['*']}), + 'img': new HtmlTagDefinition({closedByChildren: ['*']}), + 'input': new HtmlTagDefinition({closedByChildren: ['*']}), + 'p': new HtmlTagDefinition({closedByChildren: ['p']}), + 'tr': new HtmlTagDefinition({closedByChildren: ['tr'], requiredParent: 'tbody'}), + 'col': new HtmlTagDefinition({closedByChildren: ['col'], requiredParent: 'colgroup'}), + 'svg': new HtmlTagDefinition({implicitNamespacePrefix: 'svg'}), + 'math': new HtmlTagDefinition({implicitNamespacePrefix: 'math'}), + 'style': new HtmlTagDefinition({contentType: HtmlTagContentType.RAW_TEXT}), + 'script': new HtmlTagDefinition({contentType: HtmlTagContentType.RAW_TEXT}), + 'title': new HtmlTagDefinition({contentType: HtmlTagContentType.ESCAPABLE_RAW_TEXT}), + 'textarea': new HtmlTagDefinition({contentType: HtmlTagContentType.ESCAPABLE_RAW_TEXT}) +}; + +var DEFAULT_TAG_DEFINITION = new HtmlTagDefinition(); + +export function getHtmlTagDefinition(tagName: string): HtmlTagDefinition { + var result = TAG_DEFINITIONS[tagName.toLowerCase()]; + return isPresent(result) ? result : DEFAULT_TAG_DEFINITION; +} diff --git a/modules/angular2/src/compiler/parse_util.ts b/modules/angular2/src/compiler/parse_util.ts new file mode 100644 index 0000000000..4c9413cce8 --- /dev/null +++ b/modules/angular2/src/compiler/parse_util.ts @@ -0,0 +1,31 @@ +import {Math} from 'angular2/src/facade/math'; + +export class ParseLocation { + constructor(public file: ParseSourceFile, public offset: number, public line: number, + public col: number) {} + + toString() { return `${this.file.url}@${this.line}:${this.col}`; } +} + +export class ParseSourceFile { + constructor(public content: string, public url: string) {} +} + +export abstract class ParseError { + constructor(public location: ParseLocation, public msg: string) {} + + toString(): string { + var source = this.location.file.content; + var ctxStart = Math.max(this.location.offset - 10, 0); + var ctxEnd = Math.min(this.location.offset + 10, source.length); + return `${this.msg} (${source.substring(ctxStart, ctxEnd)}): ${this.location}`; + } +} + +export class ParseSourceSpan { + constructor(public start: ParseLocation, public end: ParseLocation) {} + + toString(): string { + return this.start.file.content.substring(this.start.offset, this.end.offset); + } +} diff --git a/modules/angular2/src/compiler/template_ast.ts b/modules/angular2/src/compiler/template_ast.ts index 10a92a6d36..8c31180f96 100644 --- a/modules/angular2/src/compiler/template_ast.ts +++ b/modules/angular2/src/compiler/template_ast.ts @@ -1,32 +1,35 @@ import {AST} from 'angular2/src/core/change_detection/change_detection'; import {isPresent} from 'angular2/src/facade/lang'; import {CompileDirectiveMetadata} from './directive_metadata'; +import {ParseSourceSpan} from './parse_util'; export interface TemplateAst { - sourceInfo: string; + sourceSpan: ParseSourceSpan; visit(visitor: TemplateAstVisitor, context: any): any; } export class TextAst implements TemplateAst { - constructor(public value: string, public ngContentIndex: number, public sourceInfo: string) {} + constructor(public value: string, public ngContentIndex: number, + public sourceSpan: ParseSourceSpan) {} visit(visitor: TemplateAstVisitor, context: any): any { return visitor.visitText(this, context); } } export class BoundTextAst implements TemplateAst { - constructor(public value: AST, public ngContentIndex: number, public sourceInfo: string) {} + constructor(public value: AST, public ngContentIndex: number, + public sourceSpan: ParseSourceSpan) {} visit(visitor: TemplateAstVisitor, context: any): any { return visitor.visitBoundText(this, context); } } export class AttrAst implements TemplateAst { - constructor(public name: string, public value: string, public sourceInfo: string) {} + constructor(public name: string, public value: string, public sourceSpan: ParseSourceSpan) {} visit(visitor: TemplateAstVisitor, context: any): any { return visitor.visitAttr(this, context); } } export class BoundElementPropertyAst implements TemplateAst { constructor(public name: string, public type: PropertyBindingType, public value: AST, - public unit: string, public sourceInfo: string) {} + public unit: string, public sourceSpan: ParseSourceSpan) {} visit(visitor: TemplateAstVisitor, context: any): any { return visitor.visitElementProperty(this, context); } @@ -34,7 +37,7 @@ export class BoundElementPropertyAst implements TemplateAst { export class BoundEventAst implements TemplateAst { constructor(public name: string, public target: string, public handler: AST, - public sourceInfo: string) {} + public sourceSpan: ParseSourceSpan) {} visit(visitor: TemplateAstVisitor, context: any): any { return visitor.visitEvent(this, context); } @@ -48,7 +51,7 @@ export class BoundEventAst implements TemplateAst { } export class VariableAst implements TemplateAst { - constructor(public name: string, public value: string, public sourceInfo: string) {} + constructor(public name: string, public value: string, public sourceSpan: ParseSourceSpan) {} visit(visitor: TemplateAstVisitor, context: any): any { return visitor.visitVariable(this, context); } @@ -59,7 +62,7 @@ export class ElementAst implements TemplateAst { public inputs: BoundElementPropertyAst[], public outputs: BoundEventAst[], public exportAsVars: VariableAst[], public directives: DirectiveAst[], public children: TemplateAst[], public ngContentIndex: number, - public sourceInfo: string) {} + public sourceSpan: ParseSourceSpan) {} visit(visitor: TemplateAstVisitor, context: any): any { return visitor.visitElement(this, context); } @@ -79,7 +82,7 @@ export class ElementAst implements TemplateAst { export class EmbeddedTemplateAst implements TemplateAst { constructor(public attrs: AttrAst[], public outputs: BoundEventAst[], public vars: VariableAst[], public directives: DirectiveAst[], public children: TemplateAst[], - public ngContentIndex: number, public sourceInfo: string) {} + public ngContentIndex: number, public sourceSpan: ParseSourceSpan) {} visit(visitor: TemplateAstVisitor, context: any): any { return visitor.visitEmbeddedTemplate(this, context); } @@ -87,7 +90,7 @@ export class EmbeddedTemplateAst implements TemplateAst { export class BoundDirectivePropertyAst implements TemplateAst { constructor(public directiveName: string, public templateName: string, public value: AST, - public sourceInfo: string) {} + public sourceSpan: ParseSourceSpan) {} visit(visitor: TemplateAstVisitor, context: any): any { return visitor.visitDirectiveProperty(this, context); } @@ -97,14 +100,15 @@ export class DirectiveAst implements TemplateAst { constructor(public directive: CompileDirectiveMetadata, public inputs: BoundDirectivePropertyAst[], public hostProperties: BoundElementPropertyAst[], public hostEvents: BoundEventAst[], - public exportAsVars: VariableAst[], public sourceInfo: string) {} + public exportAsVars: VariableAst[], public sourceSpan: ParseSourceSpan) {} visit(visitor: TemplateAstVisitor, context: any): any { return visitor.visitDirective(this, context); } } export class NgContentAst implements TemplateAst { - constructor(public index: number, public ngContentIndex: number, public sourceInfo: string) {} + constructor(public index: number, public ngContentIndex: number, + public sourceSpan: ParseSourceSpan) {} visit(visitor: TemplateAstVisitor, context: any): any { return visitor.visitNgContent(this, context); } diff --git a/modules/angular2/src/compiler/template_normalizer.ts b/modules/angular2/src/compiler/template_normalizer.ts index b61e5594a0..9ce943ebd7 100644 --- a/modules/angular2/src/compiler/template_normalizer.ts +++ b/modules/angular2/src/compiler/template_normalizer.ts @@ -29,7 +29,7 @@ import {preparseElement, PreparsedElement, PreparsedElementType} from './templat @Injectable() export class TemplateNormalizer { constructor(private _xhr: XHR, private _urlResolver: UrlResolver, - private _domParser: HtmlParser) {} + private _htmlParser: HtmlParser) {} normalizeTemplate(directiveType: CompileTypeMetadata, template: CompileTemplateMetadata): Promise { @@ -48,9 +48,14 @@ export class TemplateNormalizer { normalizeLoadedTemplate(directiveType: CompileTypeMetadata, templateMeta: CompileTemplateMetadata, template: string, templateAbsUrl: string): CompileTemplateMetadata { - var domNodes = this._domParser.parse(template, directiveType.name); + var rootNodesAndErrors = this._htmlParser.parse(template, directiveType.name); + if (rootNodesAndErrors.errors.length > 0) { + var errorString = rootNodesAndErrors.errors.join('\n'); + throw new BaseException(`Template parse errors:\n${errorString}`); + } + var visitor = new TemplatePreparseVisitor(); - htmlVisitAll(visitor, domNodes); + htmlVisitAll(visitor, rootNodesAndErrors.rootNodes); var allStyles = templateMeta.styles.concat(visitor.styles); var allStyleAbsUrls = diff --git a/modules/angular2/src/compiler/template_parser.ts b/modules/angular2/src/compiler/template_parser.ts index 68c3ae8614..40d279b4b9 100644 --- a/modules/angular2/src/compiler/template_parser.ts +++ b/modules/angular2/src/compiler/template_parser.ts @@ -19,6 +19,8 @@ import {Parser, AST, ASTWithSource} from 'angular2/src/core/change_detection/cha import {TemplateBinding} from 'angular2/src/core/change_detection/parser/ast'; import {CompileDirectiveMetadata} from './directive_metadata'; import {HtmlParser} from './html_parser'; +import {ParseSourceSpan, ParseError, ParseLocation} from './parse_util'; + import { ElementAst, @@ -69,25 +71,30 @@ const TEMPLATE_ATTR = 'template'; const TEMPLATE_ATTR_PREFIX = '*'; const CLASS_ATTR = 'class'; -var PROPERTY_PARTS_SEPARATOR = new RegExp('\\.'); +var PROPERTY_PARTS_SEPARATOR = '.'; const ATTRIBUTE_PREFIX = 'attr'; const CLASS_PREFIX = 'class'; const STYLE_PREFIX = 'style'; var TEXT_CSS_SELECTOR = CssSelector.parse('*')[0]; +export class TemplateParseError extends ParseError { + constructor(message: string, location: ParseLocation) { super(location, message); } +} + @Injectable() export class TemplateParser { constructor(private _exprParser: Parser, private _schemaRegistry: ElementSchemaRegistry, private _htmlParser: HtmlParser) {} parse(template: string, directives: CompileDirectiveMetadata[], - sourceInfo: string): TemplateAst[] { + templateUrl: string): TemplateAst[] { var parseVisitor = new TemplateParseVisitor(directives, this._exprParser, this._schemaRegistry); - var result = - htmlVisitAll(parseVisitor, this._htmlParser.parse(template, sourceInfo), EMPTY_COMPONENT); - if (parseVisitor.errors.length > 0) { - var errorString = parseVisitor.errors.join('\n'); + var htmlAstWithErrors = this._htmlParser.parse(template, templateUrl); + var result = htmlVisitAll(parseVisitor, htmlAstWithErrors.rootNodes, EMPTY_COMPONENT); + var errors: ParseError[] = htmlAstWithErrors.errors.concat(parseVisitor.errors); + if (errors.length > 0) { + var errorString = errors.join('\n'); throw new BaseException(`Template parse errors:\n${errorString}`); } return result; @@ -96,7 +103,7 @@ export class TemplateParser { class TemplateParseVisitor implements HtmlAstVisitor { selectorMatcher: SelectorMatcher; - errors: string[] = []; + errors: TemplateParseError[] = []; directivesIndex = new Map(); ngContentCount: number = 0; @@ -111,56 +118,62 @@ class TemplateParseVisitor implements HtmlAstVisitor { }); } - private _reportError(message: string) { this.errors.push(message); } + private _reportError(message: string, sourceSpan: ParseSourceSpan) { + this.errors.push(new TemplateParseError(message, sourceSpan.start)); + } - private _parseInterpolation(value: string, sourceInfo: string): ASTWithSource { + private _parseInterpolation(value: string, sourceSpan: ParseSourceSpan): ASTWithSource { + var sourceInfo = sourceSpan.start.toString(); try { return this._exprParser.parseInterpolation(value, sourceInfo); } catch (e) { - this._reportError(`${e}`); // sourceInfo is already contained in the AST + this._reportError(`${e}`, sourceSpan); return this._exprParser.wrapLiteralPrimitive('ERROR', sourceInfo); } } - private _parseAction(value: string, sourceInfo: string): ASTWithSource { + private _parseAction(value: string, sourceSpan: ParseSourceSpan): ASTWithSource { + var sourceInfo = sourceSpan.start.toString(); try { return this._exprParser.parseAction(value, sourceInfo); } catch (e) { - this._reportError(`${e}`); // sourceInfo is already contained in the AST + this._reportError(`${e}`, sourceSpan); return this._exprParser.wrapLiteralPrimitive('ERROR', sourceInfo); } } - private _parseBinding(value: string, sourceInfo: string): ASTWithSource { + private _parseBinding(value: string, sourceSpan: ParseSourceSpan): ASTWithSource { + var sourceInfo = sourceSpan.start.toString(); try { return this._exprParser.parseBinding(value, sourceInfo); } catch (e) { - this._reportError(`${e}`); // sourceInfo is already contained in the AST + this._reportError(`${e}`, sourceSpan); return this._exprParser.wrapLiteralPrimitive('ERROR', sourceInfo); } } - private _parseTemplateBindings(value: string, sourceInfo: string): TemplateBinding[] { + private _parseTemplateBindings(value: string, sourceSpan: ParseSourceSpan): TemplateBinding[] { + var sourceInfo = sourceSpan.start.toString(); try { return this._exprParser.parseTemplateBindings(value, sourceInfo); } catch (e) { - this._reportError(`${e}`); // sourceInfo is already contained in the AST + this._reportError(`${e}`, sourceSpan); return []; } } visitText(ast: HtmlTextAst, component: Component): any { var ngContentIndex = component.findNgContentIndex(TEXT_CSS_SELECTOR); - var expr = this._parseInterpolation(ast.value, ast.sourceInfo); + var expr = this._parseInterpolation(ast.value, ast.sourceSpan); if (isPresent(expr)) { - return new BoundTextAst(expr, ngContentIndex, ast.sourceInfo); + return new BoundTextAst(expr, ngContentIndex, ast.sourceSpan); } else { - return new TextAst(ast.value, ngContentIndex, ast.sourceInfo); + return new TextAst(ast.value, ngContentIndex, ast.sourceSpan); } } visitAttr(ast: HtmlAttrAst, contex: any): any { - return new AttrAst(ast.name, ast.value, ast.sourceInfo); + return new AttrAst(ast.name, ast.value, ast.sourceSpan); } visitElement(element: HtmlElementAst, component: Component): any { @@ -176,8 +189,7 @@ class TemplateParseVisitor implements HtmlAstVisitor { if (preparsedElement.type === PreparsedElementType.STYLESHEET && isStyleUrlResolvable(preparsedElement.hrefAttr)) { // Skipping stylesheets with either relative urls or package scheme as we already processed - // them - // in the StyleCompiler + // them in the StyleCompiler return null; } @@ -191,6 +203,7 @@ class TemplateParseVisitor implements HtmlAstVisitor { var templateMatchableAttrs: string[][] = []; var hasInlineTemplates = false; var attrs = []; + element.attrs.forEach(attr => { matchableAttrs.push([attr.name, attr.value]); var hasBinding = this._parseAttr(attr, matchableAttrs, elementOrDirectiveProps, events, vars); @@ -204,11 +217,12 @@ class TemplateParseVisitor implements HtmlAstVisitor { hasInlineTemplates = true; } }); + var isTemplateElement = nodeName == TEMPLATE_ELEMENT; var elementCssSelector = createElementCssSelector(nodeName, matchableAttrs); var directives = this._createDirectiveAsts( element.name, this._parseDirectives(this.selectorMatcher, elementCssSelector), - elementOrDirectiveProps, isTemplateElement ? [] : vars, element.sourceInfo); + elementOrDirectiveProps, isTemplateElement ? [] : vars, element.sourceSpan); var elementProps: BoundElementPropertyAst[] = this._createElementPropertyAsts(element.name, elementOrDirectiveProps, directives); var children = htmlVisitAll(preparsedElement.nonBindable ? NON_BINDABLE_VISITOR : this, @@ -218,32 +232,32 @@ class TemplateParseVisitor implements HtmlAstVisitor { var parsedElement; if (preparsedElement.type === PreparsedElementType.NG_CONTENT) { parsedElement = - new NgContentAst(this.ngContentCount++, elementNgContentIndex, element.sourceInfo); + new NgContentAst(this.ngContentCount++, elementNgContentIndex, element.sourceSpan); } else if (isTemplateElement) { - this._assertAllEventsPublishedByDirectives(directives, events, element.sourceInfo); + this._assertAllEventsPublishedByDirectives(directives, events); this._assertNoComponentsNorElementBindingsOnTemplate(directives, elementProps, - element.sourceInfo); + element.sourceSpan); parsedElement = new EmbeddedTemplateAst(attrs, events, vars, directives, children, - elementNgContentIndex, element.sourceInfo); + elementNgContentIndex, element.sourceSpan); } else { - this._assertOnlyOneComponent(directives, element.sourceInfo); + this._assertOnlyOneComponent(directives, element.sourceSpan); var elementExportAsVars = vars.filter(varAst => varAst.value.length === 0); parsedElement = new ElementAst(nodeName, attrs, elementProps, events, elementExportAsVars, directives, - children, elementNgContentIndex, element.sourceInfo); + children, elementNgContentIndex, element.sourceSpan); } if (hasInlineTemplates) { var templateCssSelector = createElementCssSelector(TEMPLATE_ELEMENT, templateMatchableAttrs); var templateDirectives = this._createDirectiveAsts( element.name, this._parseDirectives(this.selectorMatcher, templateCssSelector), - templateElementOrDirectiveProps, [], element.sourceInfo); + templateElementOrDirectiveProps, [], element.sourceSpan); var templateElementProps: BoundElementPropertyAst[] = this._createElementPropertyAsts( element.name, templateElementOrDirectiveProps, templateDirectives); this._assertNoComponentsNorElementBindingsOnTemplate(templateDirectives, templateElementProps, - element.sourceInfo); + element.sourceSpan); parsedElement = new EmbeddedTemplateAst( [], [], templateVars, templateDirectives, [parsedElement], - component.findNgContentIndex(templateCssSelector), element.sourceInfo); + component.findNgContentIndex(templateCssSelector), element.sourceSpan); } return parsedElement; } @@ -259,20 +273,20 @@ class TemplateParseVisitor implements HtmlAstVisitor { templateBindingsSource = (attr.value.length == 0) ? key : key + ' ' + attr.value; } if (isPresent(templateBindingsSource)) { - var bindings = this._parseTemplateBindings(templateBindingsSource, attr.sourceInfo); + var bindings = this._parseTemplateBindings(templateBindingsSource, attr.sourceSpan); for (var i = 0; i < bindings.length; i++) { var binding = bindings[i]; var dashCaseKey = camelCaseToDashCase(binding.key); if (binding.keyIsVar) { targetVars.push( - new VariableAst(dashCaseToCamelCase(binding.key), binding.name, attr.sourceInfo)); + new VariableAst(dashCaseToCamelCase(binding.key), binding.name, attr.sourceSpan)); targetMatchableAttrs.push([dashCaseKey, binding.name]); } else if (isPresent(binding.expression)) { - this._parsePropertyAst(dashCaseKey, binding.expression, attr.sourceInfo, + this._parsePropertyAst(dashCaseKey, binding.expression, attr.sourceSpan, targetMatchableAttrs, targetProps); } else { targetMatchableAttrs.push([dashCaseKey, '']); - this._parseLiteralAttr(dashCaseKey, null, attr.sourceInfo, targetProps); + this._parseLiteralAttr(dashCaseKey, null, attr.sourceSpan, targetProps); } } return true; @@ -290,44 +304,44 @@ class TemplateParseVisitor implements HtmlAstVisitor { if (isPresent(bindParts)) { hasBinding = true; if (isPresent(bindParts[1])) { // match: bind-prop - this._parseProperty(bindParts[5], attrValue, attr.sourceInfo, targetMatchableAttrs, + this._parseProperty(bindParts[5], attrValue, attr.sourceSpan, targetMatchableAttrs, targetProps); } else if (isPresent( bindParts[2])) { // match: var-name / var-name="iden" / #name / #name="iden" var identifier = bindParts[5]; - this._parseVariable(identifier, attrValue, attr.sourceInfo, targetVars); + this._parseVariable(identifier, attrValue, attr.sourceSpan, targetVars); } else if (isPresent(bindParts[3])) { // match: on-event - this._parseEvent(bindParts[5], attrValue, attr.sourceInfo, targetMatchableAttrs, + this._parseEvent(bindParts[5], attrValue, attr.sourceSpan, targetMatchableAttrs, targetEvents); } else if (isPresent(bindParts[4])) { // match: bindon-prop - this._parseProperty(bindParts[5], attrValue, attr.sourceInfo, targetMatchableAttrs, + this._parseProperty(bindParts[5], attrValue, attr.sourceSpan, targetMatchableAttrs, targetProps); - this._parseAssignmentEvent(bindParts[5], attrValue, attr.sourceInfo, targetMatchableAttrs, + this._parseAssignmentEvent(bindParts[5], attrValue, attr.sourceSpan, targetMatchableAttrs, targetEvents); } else if (isPresent(bindParts[6])) { // match: [(expr)] - this._parseProperty(bindParts[6], attrValue, attr.sourceInfo, targetMatchableAttrs, + this._parseProperty(bindParts[6], attrValue, attr.sourceSpan, targetMatchableAttrs, targetProps); - this._parseAssignmentEvent(bindParts[6], attrValue, attr.sourceInfo, targetMatchableAttrs, + this._parseAssignmentEvent(bindParts[6], attrValue, attr.sourceSpan, targetMatchableAttrs, targetEvents); } else if (isPresent(bindParts[7])) { // match: [expr] - this._parseProperty(bindParts[7], attrValue, attr.sourceInfo, targetMatchableAttrs, + this._parseProperty(bindParts[7], attrValue, attr.sourceSpan, targetMatchableAttrs, targetProps); } else if (isPresent(bindParts[8])) { // match: (event) - this._parseEvent(bindParts[8], attrValue, attr.sourceInfo, targetMatchableAttrs, + this._parseEvent(bindParts[8], attrValue, attr.sourceSpan, targetMatchableAttrs, targetEvents); } } else { - hasBinding = this._parsePropertyInterpolation(attrName, attrValue, attr.sourceInfo, + hasBinding = this._parsePropertyInterpolation(attrName, attrValue, attr.sourceSpan, targetMatchableAttrs, targetProps); } if (!hasBinding) { - this._parseLiteralAttr(attrName, attrValue, attr.sourceInfo, targetProps); + this._parseLiteralAttr(attrName, attrValue, attr.sourceSpan, targetProps); } return hasBinding; } @@ -336,59 +350,59 @@ class TemplateParseVisitor implements HtmlAstVisitor { return attrName.startsWith('data-') ? attrName.substring(5) : attrName; } - private _parseVariable(identifier: string, value: string, sourceInfo: any, + private _parseVariable(identifier: string, value: string, sourceSpan: ParseSourceSpan, targetVars: VariableAst[]) { - targetVars.push(new VariableAst(dashCaseToCamelCase(identifier), value, sourceInfo)); + targetVars.push(new VariableAst(dashCaseToCamelCase(identifier), value, sourceSpan)); } - private _parseProperty(name: string, expression: string, sourceInfo: any, + private _parseProperty(name: string, expression: string, sourceSpan: ParseSourceSpan, targetMatchableAttrs: string[][], targetProps: BoundElementOrDirectiveProperty[]) { - this._parsePropertyAst(name, this._parseBinding(expression, sourceInfo), sourceInfo, + this._parsePropertyAst(name, this._parseBinding(expression, sourceSpan), sourceSpan, targetMatchableAttrs, targetProps); } - private _parsePropertyInterpolation(name: string, value: string, sourceInfo: any, + private _parsePropertyInterpolation(name: string, value: string, sourceSpan: ParseSourceSpan, targetMatchableAttrs: string[][], targetProps: BoundElementOrDirectiveProperty[]): boolean { - var expr = this._parseInterpolation(value, sourceInfo); + var expr = this._parseInterpolation(value, sourceSpan); if (isPresent(expr)) { - this._parsePropertyAst(name, expr, sourceInfo, targetMatchableAttrs, targetProps); + this._parsePropertyAst(name, expr, sourceSpan, targetMatchableAttrs, targetProps); return true; } return false; } - private _parsePropertyAst(name: string, ast: ASTWithSource, sourceInfo: any, + private _parsePropertyAst(name: string, ast: ASTWithSource, sourceSpan: ParseSourceSpan, targetMatchableAttrs: string[][], targetProps: BoundElementOrDirectiveProperty[]) { targetMatchableAttrs.push([name, ast.source]); - targetProps.push(new BoundElementOrDirectiveProperty(name, ast, false, sourceInfo)); + targetProps.push(new BoundElementOrDirectiveProperty(name, ast, false, sourceSpan)); } - private _parseAssignmentEvent(name: string, expression: string, sourceInfo: string, + private _parseAssignmentEvent(name: string, expression: string, sourceSpan: ParseSourceSpan, targetMatchableAttrs: string[][], targetEvents: BoundEventAst[]) { - this._parseEvent(`${name}-change`, `${expression}=$event`, sourceInfo, targetMatchableAttrs, + this._parseEvent(`${name}-change`, `${expression}=$event`, sourceSpan, targetMatchableAttrs, targetEvents); } - private _parseEvent(name: string, expression: string, sourceInfo: string, + private _parseEvent(name: string, expression: string, sourceSpan: ParseSourceSpan, targetMatchableAttrs: string[][], targetEvents: BoundEventAst[]) { // long format: 'target: eventName' var parts = splitAtColon(name, [null, name]); var target = parts[0]; var eventName = parts[1]; targetEvents.push(new BoundEventAst(dashCaseToCamelCase(eventName), target, - this._parseAction(expression, sourceInfo), sourceInfo)); + this._parseAction(expression, sourceSpan), sourceSpan)); // Don't detect directives for event names for now, // so don't add the event name to the matchableAttrs } - private _parseLiteralAttr(name: string, value: string, sourceInfo: string, + private _parseLiteralAttr(name: string, value: string, sourceSpan: ParseSourceSpan, targetProps: BoundElementOrDirectiveProperty[]) { targetProps.push(new BoundElementOrDirectiveProperty( - dashCaseToCamelCase(name), this._exprParser.wrapLiteralPrimitive(value, sourceInfo), true, - sourceInfo)); + dashCaseToCamelCase(name), this._exprParser.wrapLiteralPrimitive(value, ''), true, + sourceSpan)); } private _parseDirectives(selectorMatcher: SelectorMatcher, @@ -417,15 +431,15 @@ class TemplateParseVisitor implements HtmlAstVisitor { private _createDirectiveAsts(elementName: string, directives: CompileDirectiveMetadata[], props: BoundElementOrDirectiveProperty[], possibleExportAsVars: VariableAst[], - sourceInfo: string): DirectiveAst[] { + sourceSpan: ParseSourceSpan): DirectiveAst[] { var matchedVariables = new Set(); var directiveAsts = directives.map((directive: CompileDirectiveMetadata) => { var hostProperties: BoundElementPropertyAst[] = []; var hostEvents: BoundEventAst[] = []; var directiveProperties: BoundDirectivePropertyAst[] = []; - this._createDirectiveHostPropertyAsts(elementName, directive.hostProperties, sourceInfo, + this._createDirectiveHostPropertyAsts(elementName, directive.hostProperties, sourceSpan, hostProperties); - this._createDirectiveHostEventAsts(directive.hostListeners, sourceInfo, hostEvents); + this._createDirectiveHostEventAsts(directive.hostListeners, sourceSpan, hostEvents); this._createDirectivePropertyAsts(directive.inputs, props, directiveProperties); var exportAsVars = []; possibleExportAsVars.forEach((varAst) => { @@ -436,34 +450,35 @@ class TemplateParseVisitor implements HtmlAstVisitor { } }); return new DirectiveAst(directive, directiveProperties, hostProperties, hostEvents, - exportAsVars, sourceInfo); + exportAsVars, sourceSpan); }); possibleExportAsVars.forEach((varAst) => { if (varAst.value.length > 0 && !SetWrapper.has(matchedVariables, varAst.name)) { - this._reportError( - `There is no directive with "exportAs" set to "${varAst.value}" at ${varAst.sourceInfo}`); + this._reportError(`There is no directive with "exportAs" set to "${varAst.value}"`, + varAst.sourceSpan); } }); return directiveAsts; } private _createDirectiveHostPropertyAsts(elementName: string, hostProps: {[key: string]: string}, - sourceInfo: string, + sourceSpan: ParseSourceSpan, targetPropertyAsts: BoundElementPropertyAst[]) { if (isPresent(hostProps)) { StringMapWrapper.forEach(hostProps, (expression, propName) => { - var exprAst = this._parseBinding(expression, sourceInfo); + var exprAst = this._parseBinding(expression, sourceSpan); targetPropertyAsts.push( - this._createElementPropertyAst(elementName, propName, exprAst, sourceInfo)); + this._createElementPropertyAst(elementName, propName, exprAst, sourceSpan)); }); } } - private _createDirectiveHostEventAsts(hostListeners: {[key: string]: string}, sourceInfo: string, + private _createDirectiveHostEventAsts(hostListeners: {[key: string]: string}, + sourceSpan: ParseSourceSpan, targetEventAsts: BoundEventAst[]) { if (isPresent(hostListeners)) { StringMapWrapper.forEach(hostListeners, (expression, propName) => { - this._parseEvent(propName, expression, sourceInfo, [], targetEventAsts); + this._parseEvent(propName, expression, sourceSpan, [], targetEventAsts); }); } } @@ -489,7 +504,7 @@ class TemplateParseVisitor implements HtmlAstVisitor { // Bindings are optional, so this binding only needs to be set up if an expression is given. if (isPresent(boundProp)) { targetBoundDirectiveProps.push(new BoundDirectivePropertyAst( - dirProp, boundProp.name, boundProp.expression, boundProp.sourceInfo)); + dirProp, boundProp.name, boundProp.expression, boundProp.sourceSpan)); } }); } @@ -507,24 +522,25 @@ class TemplateParseVisitor implements HtmlAstVisitor { props.forEach((prop: BoundElementOrDirectiveProperty) => { if (!prop.isLiteral && isBlank(boundDirectivePropsIndex.get(prop.name))) { boundElementProps.push(this._createElementPropertyAst(elementName, prop.name, - prop.expression, prop.sourceInfo)); + prop.expression, prop.sourceSpan)); } }); return boundElementProps; } private _createElementPropertyAst(elementName: string, name: string, ast: AST, - sourceInfo: any): BoundElementPropertyAst { + sourceSpan: ParseSourceSpan): BoundElementPropertyAst { var unit = null; var bindingType; var boundPropertyName; - var parts = StringWrapper.split(name, PROPERTY_PARTS_SEPARATOR); + var parts = name.split(PROPERTY_PARTS_SEPARATOR); if (parts.length === 1) { boundPropertyName = this._schemaRegistry.getMappedPropName(dashCaseToCamelCase(parts[0])); bindingType = PropertyBindingType.Property; if (!this._schemaRegistry.hasProperty(elementName, boundPropertyName)) { this._reportError( - `Can't bind to '${boundPropertyName}' since it isn't a known native property in ${sourceInfo}`); + `Can't bind to '${boundPropertyName}' since it isn't a known native property`, + sourceSpan); } } else if (parts[0] == ATTRIBUTE_PREFIX) { boundPropertyName = dashCaseToCamelCase(parts[1]); @@ -538,10 +554,10 @@ class TemplateParseVisitor implements HtmlAstVisitor { boundPropertyName = dashCaseToCamelCase(parts[1]); bindingType = PropertyBindingType.Style; } else { - this._reportError(`Invalid property name ${name} in ${sourceInfo}`); + this._reportError(`Invalid property name ${name}`, sourceSpan); bindingType = null; } - return new BoundElementPropertyAst(boundPropertyName, bindingType, ast, unit, sourceInfo); + return new BoundElementPropertyAst(boundPropertyName, bindingType, ast, unit, sourceSpan); } @@ -556,30 +572,30 @@ class TemplateParseVisitor implements HtmlAstVisitor { return componentTypeNames; } - private _assertOnlyOneComponent(directives: DirectiveAst[], sourceInfo: string) { + private _assertOnlyOneComponent(directives: DirectiveAst[], sourceSpan: ParseSourceSpan) { var componentTypeNames = this._findComponentDirectiveNames(directives); if (componentTypeNames.length > 1) { - this._reportError( - `More than one component: ${componentTypeNames.join(',')} in ${sourceInfo}`); + this._reportError(`More than one component: ${componentTypeNames.join(',')}`, sourceSpan); } } private _assertNoComponentsNorElementBindingsOnTemplate(directives: DirectiveAst[], elementProps: BoundElementPropertyAst[], - sourceInfo: string) { + sourceSpan: ParseSourceSpan) { var componentTypeNames: string[] = this._findComponentDirectiveNames(directives); if (componentTypeNames.length > 0) { - this._reportError( - `Components on an embedded template: ${componentTypeNames.join(',')} in ${sourceInfo}`); + this._reportError(`Components on an embedded template: ${componentTypeNames.join(',')}`, + sourceSpan); } elementProps.forEach(prop => { this._reportError( - `Property binding ${prop.name} not used by any directive on an embedded template in ${prop.sourceInfo}`); + `Property binding ${prop.name} not used by any directive on an embedded template`, + sourceSpan); }); } - private _assertAllEventsPublishedByDirectives(directives: DirectiveAst[], events: BoundEventAst[], - sourceInfo: string) { + private _assertAllEventsPublishedByDirectives(directives: DirectiveAst[], + events: BoundEventAst[]) { var allDirectiveEvents = new Set(); directives.forEach(directive => { StringMapWrapper.forEach(directive.directive.outputs, @@ -588,7 +604,8 @@ class TemplateParseVisitor implements HtmlAstVisitor { events.forEach(event => { if (isPresent(event.target) || !SetWrapper.has(allDirectiveEvents, event.name)) { this._reportError( - `Event binding ${event.fullName} not emitted by any directive on an embedded template in ${sourceInfo}`); + `Event binding ${event.fullName} not emitted by any directive on an embedded template`, + event.sourceSpan); } }); } @@ -611,20 +628,20 @@ class NonBindableVisitor implements HtmlAstVisitor { var ngContentIndex = component.findNgContentIndex(selector); var children = htmlVisitAll(this, ast.children, EMPTY_COMPONENT); return new ElementAst(ast.name, htmlVisitAll(this, ast.attrs), [], [], [], [], children, - ngContentIndex, ast.sourceInfo); + ngContentIndex, ast.sourceSpan); } visitAttr(ast: HtmlAttrAst, context: any): AttrAst { - return new AttrAst(ast.name, ast.value, ast.sourceInfo); + return new AttrAst(ast.name, ast.value, ast.sourceSpan); } visitText(ast: HtmlTextAst, component: Component): TextAst { var ngContentIndex = component.findNgContentIndex(TEXT_CSS_SELECTOR); - return new TextAst(ast.value, ngContentIndex, ast.sourceInfo); + return new TextAst(ast.value, ngContentIndex, ast.sourceSpan); } } class BoundElementOrDirectiveProperty { constructor(public name: string, public expression: AST, public isLiteral: boolean, - public sourceInfo: string) {} + public sourceSpan: ParseSourceSpan) {} } export function splitClasses(classAttrValue: string): string[] { diff --git a/modules/angular2/test/compiler/command_compiler_spec.ts b/modules/angular2/test/compiler/command_compiler_spec.ts index 81c85d10f7..e49802bde4 100644 --- a/modules/angular2/test/compiler/command_compiler_spec.ts +++ b/modules/angular2/test/compiler/command_compiler_spec.ts @@ -380,7 +380,7 @@ export function main() { run(rootComp, [dir], 1) .then((data) => { expect(data[0][2]) - .toEqual(['someEmptyVar', '$implicit', 'someVar', 'someValue']); + .toEqual(['someVar', 'someValue', 'someEmptyVar', '$implicit']); async.done(); }); })); diff --git a/modules/angular2/test/compiler/html_lexer_spec.ts b/modules/angular2/test/compiler/html_lexer_spec.ts new file mode 100644 index 0000000000..8c7d74d9d2 --- /dev/null +++ b/modules/angular2/test/compiler/html_lexer_spec.ts @@ -0,0 +1,523 @@ +import {ddescribe, describe, it, iit, xit, expect, beforeEach, afterEach} from '../../test_lib'; +import {BaseException} from '../../src/facade/exceptions'; + +import {tokenizeHtml, HtmlToken, HtmlTokenType} from '../../src/compiler/html_lexer'; +import {ParseSourceSpan, ParseLocation} from '../../src/compiler/parse_util'; + +export function main() { + describe('HtmlLexer', () => { + describe('line/column numbers', () => { + it('should work without newlines', () => { + expect(tokenizeAndHumanizeLineColumn('a')) + .toEqual([ + [HtmlTokenType.TAG_OPEN_START, '0:0'], + [HtmlTokenType.TAG_OPEN_END, '0:2'], + [HtmlTokenType.TEXT, '0:3'], + [HtmlTokenType.TAG_CLOSE, '0:4'], + [HtmlTokenType.EOF, '0:8'] + ]); + }); + + it('should work with one newline', () => { + expect(tokenizeAndHumanizeLineColumn('\na')) + .toEqual([ + [HtmlTokenType.TAG_OPEN_START, '0:0'], + [HtmlTokenType.TAG_OPEN_END, '0:2'], + [HtmlTokenType.TEXT, '0:3'], + [HtmlTokenType.TAG_CLOSE, '1:1'], + [HtmlTokenType.EOF, '1:5'] + ]); + }); + + it('should work with multiple newlines', () => { + expect(tokenizeAndHumanizeLineColumn('\na')) + .toEqual([ + [HtmlTokenType.TAG_OPEN_START, '0:0'], + [HtmlTokenType.TAG_OPEN_END, '1:0'], + [HtmlTokenType.TEXT, '1:1'], + [HtmlTokenType.TAG_CLOSE, '2:1'], + [HtmlTokenType.EOF, '2:5'] + ]); + }); + }); + + describe('comments', () => { + it('should parse comments', () => { + expect(tokenizeAndHumanizeParts('')) + .toEqual([ + [HtmlTokenType.COMMENT_START], + [HtmlTokenType.RAW_TEXT, 'test'], + [HtmlTokenType.COMMENT_END], + [HtmlTokenType.EOF] + ]); + }); + + it('should store the locations', () => {expect(tokenizeAndHumanizeSourceSpans('')) + .toEqual([ + [HtmlTokenType.COMMENT_START, ''], + [HtmlTokenType.EOF, ''] + ])}); + + it('should report { + expect(tokenizeAndHumanizeErrors(' { + expect(tokenizeAndHumanizeErrors('