diff --git a/modules/angular2/src/compiler/chars.ts b/modules/angular2/src/compiler/chars.ts new file mode 100644 index 0000000000..91c5ca4eff --- /dev/null +++ b/modules/angular2/src/compiler/chars.ts @@ -0,0 +1,64 @@ +export const $EOF = 0; +export const $TAB = 9; +export const $LF = 10; +export const $VTAB = 11; +export const $FF = 12; +export const $CR = 13; +export const $SPACE = 32; +export const $BANG = 33; +export const $DQ = 34; +export const $HASH = 35; +export const $$ = 36; +export const $PERCENT = 37; +export const $AMPERSAND = 38; +export const $SQ = 39; +export const $LPAREN = 40; +export const $RPAREN = 41; +export const $STAR = 42; +export const $PLUS = 43; +export const $COMMA = 44; +export const $MINUS = 45; +export const $PERIOD = 46; +export const $SLASH = 47; +export const $COLON = 58; +export const $SEMICOLON = 59; +export const $LT = 60; +export const $EQ = 61; +export const $GT = 62; +export const $QUESTION = 63; + +export const $0 = 48; +export const $9 = 57; + +export const $A = 65; +export const $E = 69; +export const $Z = 90; + +export const $LBRACKET = 91; +export const $BACKSLASH = 92; +export const $RBRACKET = 93; +export const $CARET = 94; +export const $_ = 95; + +export const $a = 97; +export const $e = 101; +export const $f = 102; +export const $n = 110; +export const $r = 114; +export const $t = 116; +export const $u = 117; +export const $v = 118; +export const $z = 122; + +export const $LBRACE = 123; +export const $BAR = 124; +export const $RBRACE = 125; +export const $NBSP = 160; + +export const $PIPE = 124; +export const $TILDA = 126; +export const $AT = 64; + +export function isWhitespace(code: number): boolean { + return (code >= $TAB && code <= $SPACE) || (code == $NBSP); +} diff --git a/modules/angular2/src/compiler/css/lexer.ts b/modules/angular2/src/compiler/css/lexer.ts new file mode 100644 index 0000000000..f7d8a251a2 --- /dev/null +++ b/modules/angular2/src/compiler/css/lexer.ts @@ -0,0 +1,751 @@ +import {NumberWrapper, StringWrapper, isPresent, resolveEnumToken} from "angular2/src/facade/lang"; +import {BaseException} from 'angular2/src/facade/exceptions'; + +import { + isWhitespace, + $EOF, + $HASH, + $TILDA, + $CARET, + $PERCENT, + $$, + $_, + $COLON, + $SQ, + $DQ, + $EQ, + $SLASH, + $BACKSLASH, + $PERIOD, + $STAR, + $PLUS, + $LPAREN, + $RPAREN, + $LBRACE, + $RBRACE, + $LBRACKET, + $RBRACKET, + $PIPE, + $COMMA, + $SEMICOLON, + $MINUS, + $BANG, + $QUESTION, + $AT, + $AMPERSAND, + $GT, + $a, + $A, + $z, + $Z, + $0, + $9, + $FF, + $CR, + $LF, + $VTAB +} from "angular2/src/compiler/chars"; + +export { + $EOF, + $AT, + $RBRACE, + $LBRACE, + $LBRACKET, + $RBRACKET, + $LPAREN, + $RPAREN, + $COMMA, + $COLON, + $SEMICOLON, + isWhitespace +} from "angular2/src/compiler/chars"; + +export enum CssTokenType { + EOF, + String, + Comment, + Identifier, + Number, + IdentifierOrNumber, + AtKeyword, + Character, + Whitespace, + Invalid +} + +export enum CssLexerMode { + ALL, + ALL_TRACK_WS, + SELECTOR, + PSEUDO_SELECTOR, + ATTRIBUTE_SELECTOR, + AT_RULE_QUERY, + MEDIA_QUERY, + BLOCK, + KEYFRAME_BLOCK, + STYLE_BLOCK, + STYLE_VALUE, + STYLE_VALUE_FUNCTION, + STYLE_CALC_FUNCTION +} + +export class LexedCssResult { + constructor(public error: CssScannerError, public token: CssToken) {} +} + +export function generateErrorMessage(input, message, errorValue, index, row, column) { + return `${message} at column ${row}:${column} in expression [` + + findProblemCode(input, errorValue, index, column) + ']'; +} + +export function findProblemCode(input, errorValue, index, column) { + var endOfProblemLine = index; + var current = charCode(input, index); + while (current > 0 && !isNewline(current)) { + current = charCode(input, ++endOfProblemLine); + } + var choppedString = input.substring(0, endOfProblemLine); + var pointerPadding = ""; + for (var i = 0; i < column; i++) { + pointerPadding += " "; + } + var pointerString = ""; + for (var i = 0; i < errorValue.length; i++) { + pointerString += "^"; + } + return choppedString + "\n" + pointerPadding + pointerString + "\n"; +} + +export class CssToken { + numValue: number; + constructor(public index: number, public column: number, public line: number, + public type: CssTokenType, public strValue: string) { + this.numValue = charCode(strValue, 0); + } +} + +export class CssLexer { + scan(text: string, trackComments: boolean = false): CssScanner { + return new CssScanner(text, trackComments); + } +} + +export class CssScannerError extends BaseException { + public rawMessage: string; + public message: string; + + constructor(public token: CssToken, message) { + super('Css Parse Error: ' + message); + this.rawMessage = message; + } + + toString(): string { return this.message; } +} + +function _trackWhitespace(mode: CssLexerMode) { + switch (mode) { + case CssLexerMode.SELECTOR: + case CssLexerMode.ALL_TRACK_WS: + case CssLexerMode.STYLE_VALUE: + return true; + + default: + return false; + } +} + +export class CssScanner { + peek: number; + peekPeek: number; + length: number = 0; + index: number = -1; + column: number = -1; + line: number = 0; + + _currentMode: CssLexerMode = CssLexerMode.BLOCK; + _currentError: CssScannerError = null; + + constructor(public input: string, private _trackComments: boolean = false) { + this.length = this.input.length; + this.peekPeek = this.peekAt(0); + this.advance(); + } + + getMode(): CssLexerMode { return this._currentMode; } + + setMode(mode: CssLexerMode) { + if (this._currentMode != mode) { + if (_trackWhitespace(this._currentMode)) { + this.consumeWhitespace(); + } + this._currentMode = mode; + } + } + + advance(): void { + if (isNewline(this.peek)) { + this.column = 0; + this.line++; + } else { + this.column++; + } + + this.index++; + this.peek = this.peekPeek; + this.peekPeek = this.peekAt(this.index + 1); + } + + peekAt(index): number { + return index >= this.length ? $EOF : StringWrapper.charCodeAt(this.input, index); + } + + consumeEmptyStatements(): void { + this.consumeWhitespace(); + while (this.peek == $SEMICOLON) { + this.advance(); + this.consumeWhitespace(); + } + } + + consumeWhitespace(): void { + while (isWhitespace(this.peek) || isNewline(this.peek)) { + this.advance(); + if (!this._trackComments && isCommentStart(this.peek, this.peekPeek)) { + this.advance(); // / + this.advance(); // * + while (!isCommentEnd(this.peek, this.peekPeek)) { + if (this.peek == $EOF) { + this.error('Unterminated comment'); + } + this.advance(); + } + this.advance(); // * + this.advance(); // / + } + } + } + + consume(type: CssTokenType, value: string = null): LexedCssResult { + var mode = this._currentMode; + this.setMode(CssLexerMode.ALL); + + var previousIndex = this.index; + var previousLine = this.line; + var previousColumn = this.column; + + var output = this.scan(); + + // just incase the inner scan method returned an error + if (isPresent(output.error)) { + this.setMode(mode); + return output; + } + + var next = output.token; + if (!isPresent(next)) { + next = new CssToken(0, 0, 0, CssTokenType.EOF, "end of file"); + } + + var isMatchingType; + if (type == CssTokenType.IdentifierOrNumber) { + // TODO (matsko): implement array traversal for lookup here + isMatchingType = next.type == CssTokenType.Number || next.type == CssTokenType.Identifier; + } else { + isMatchingType = next.type == type; + } + + // before throwing the error we need to bring back the former + // mode so that the parser can recover... + this.setMode(mode); + + var error = null; + if (!isMatchingType || (isPresent(value) && value != next.strValue)) { + var errorMessage = resolveEnumToken(CssTokenType, next.type) + " does not match expected " + + resolveEnumToken(CssTokenType, type) + " value"; + + if (isPresent(value)) { + errorMessage += ' ("' + next.strValue + '" should match "' + value + '")'; + } + + error = new CssScannerError( + next, generateErrorMessage(this.input, errorMessage, next.strValue, previousIndex, + previousLine, previousColumn)); + } + + return new LexedCssResult(error, next); + } + + + scan(): LexedCssResult { + var trackWS = _trackWhitespace(this._currentMode); + if (this.index == 0 && !trackWS) { // first scan + this.consumeWhitespace(); + } + + var token = this._scan(); + if (token == null) return null; + + var error = this._currentError; + this._currentError = null; + + if (!trackWS) { + this.consumeWhitespace(); + } + return new LexedCssResult(error, token); + } + + _scan(): CssToken { + var peek = this.peek; + var peekPeek = this.peekPeek; + if (peek == $EOF) return null; + + if (isCommentStart(peek, peekPeek)) { + // even if comments are not tracked we still lex the + // comment so we can move the pointer forward + var commentToken = this.scanComment(); + if (this._trackComments) { + return commentToken; + } + } + + if (_trackWhitespace(this._currentMode) && (isWhitespace(peek) || isNewline(peek))) { + return this.scanWhitespace(); + } + + peek = this.peek; + peekPeek = this.peekPeek; + if (peek == $EOF) return null; + + if (isStringStart(peek, peekPeek)) { + return this.scanString(); + } + + // something like url(cool) + if (this._currentMode == CssLexerMode.STYLE_VALUE_FUNCTION) { + return this.scanCssValueFunction(); + } + + var isModifier = peek == $PLUS || peek == $MINUS; + var digitA = isModifier ? false : isDigit(peek); + var digitB = isDigit(peekPeek); + if (digitA || (isModifier && (peekPeek == $PERIOD || digitB)) || (peek == $PERIOD && digitB)) { + return this.scanNumber(); + } + + if (peek == $AT) { + return this.scanAtExpression(); + } + + if (isIdentifierStart(peek, peekPeek)) { + return this.scanIdentifier(); + } + + if (isValidCssCharacter(peek, this._currentMode)) { + return this.scanCharacter(); + } + + return this.error(`Unexpected character [${StringWrapper.fromCharCode(peek)}]`); + } + + scanComment() { + if (this.assertCondition(isCommentStart(this.peek, this.peekPeek), + "Expected comment start value")) { + return null; + } + + var start = this.index; + var startingColumn = this.column; + var startingLine = this.line; + + this.advance(); // / + this.advance(); // * + + while (!isCommentEnd(this.peek, this.peekPeek)) { + if (this.peek == $EOF) { + this.error('Unterminated comment'); + } + this.advance(); + } + + this.advance(); // * + this.advance(); // / + + var str = this.input.substring(start, this.index); + return new CssToken(start, startingColumn, startingLine, CssTokenType.Comment, str); + } + + scanWhitespace() { + var start = this.index; + var startingColumn = this.column; + var startingLine = this.line; + while (isWhitespace(this.peek) && this.peek != $EOF) { + this.advance(); + } + var str = this.input.substring(start, this.index); + return new CssToken(start, startingColumn, startingLine, CssTokenType.Whitespace, str); + } + + scanString() { + if (this.assertCondition(isStringStart(this.peek, this.peekPeek), + "Unexpected non-string starting value")) { + return null; + } + + var target = this.peek; + var start = this.index; + var startingColumn = this.column; + var startingLine = this.line; + var previous = target; + this.advance(); + + while (!isCharMatch(target, previous, this.peek)) { + if (this.peek == $EOF || isNewline(this.peek)) { + this.error('Unterminated quote'); + } + previous = this.peek; + this.advance(); + } + + if (this.assertCondition(this.peek == target, "Unterminated quote")) { + return null; + } + this.advance(); + + var str = this.input.substring(start, this.index); + return new CssToken(start, startingColumn, startingLine, CssTokenType.String, str); + } + + scanNumber() { + var start = this.index; + var startingColumn = this.column; + if (this.peek == $PLUS || this.peek == $MINUS) { + this.advance(); + } + var periodUsed = false; + while (isDigit(this.peek) || this.peek == $PERIOD) { + if (this.peek == $PERIOD) { + if (periodUsed) { + this.error('Unexpected use of a second period value'); + } + periodUsed = true; + } + this.advance(); + } + var strValue = this.input.substring(start, this.index); + return new CssToken(start, startingColumn, this.line, CssTokenType.Number, strValue); + } + + scanIdentifier() { + if (this.assertCondition(isIdentifierStart(this.peek, this.peekPeek), + 'Expected identifier starting value')) { + return null; + } + + var start = this.index; + var startingColumn = this.column; + while (isIdentifierPart(this.peek)) { + this.advance(); + } + var strValue = this.input.substring(start, this.index); + return new CssToken(start, startingColumn, this.line, CssTokenType.Identifier, strValue); + } + + scanCssValueFunction() { + var start = this.index; + var startingColumn = this.column; + while (this.peek != $EOF && this.peek != $RPAREN) { + this.advance(); + } + var strValue = this.input.substring(start, this.index); + return new CssToken(start, startingColumn, this.line, CssTokenType.Identifier, strValue); + } + + scanCharacter() { + var start = this.index; + var startingColumn = this.column; + if (this.assertCondition(isValidCssCharacter(this.peek, this._currentMode), + charStr(this.peek) + ' is not a valid CSS character')) { + return null; + } + + var c = this.input.substring(start, start + 1); + this.advance(); + + return new CssToken(start, startingColumn, this.line, CssTokenType.Character, c); + } + + scanAtExpression() { + if (this.assertCondition(this.peek == $AT, 'Expected @ value')) { + return null; + } + + var start = this.index; + var startingColumn = this.column; + this.advance(); + if (isIdentifierStart(this.peek, this.peekPeek)) { + var ident = this.scanIdentifier(); + var strValue = '@' + ident.strValue; + return new CssToken(start, startingColumn, this.line, CssTokenType.AtKeyword, strValue); + } else { + return this.scanCharacter(); + } + } + + assertCondition(status: boolean, errorMessage: string): boolean { + if (!status) { + this.error(errorMessage); + return true; + } + return false; + } + + error(message: string, errorTokenValue: string = null, doNotAdvance: boolean = false): CssToken { + var index: number = this.index; + var column: number = this.column; + var line: number = this.line; + errorTokenValue = + isPresent(errorTokenValue) ? errorTokenValue : StringWrapper.fromCharCode(this.peek); + var invalidToken = new CssToken(index, column, line, CssTokenType.Invalid, errorTokenValue); + var errorMessage = + generateErrorMessage(this.input, message, errorTokenValue, index, line, column); + if (!doNotAdvance) { + this.advance(); + } + this._currentError = new CssScannerError(invalidToken, errorMessage); + return invalidToken; + } +} + +function isAtKeyword(current: CssToken, next: CssToken): boolean { + return current.numValue == $AT && next.type == CssTokenType.Identifier; +} + +function isCharMatch(target: number, previous: number, code: number) { + return code == target && previous != $BACKSLASH; +} + +function isDigit(code: number): boolean { + return $0 <= code && code <= $9; +} + +function isCommentStart(code: number, next: number) { + return code == $SLASH && next == $STAR; +} + +function isCommentEnd(code: number, next: number) { + return code == $STAR && next == $SLASH; +} + +function isStringStart(code: number, next: number): boolean { + var target = code; + if (target == $BACKSLASH) { + target = next; + } + return target == $DQ || target == $SQ; +} + +function isIdentifierStart(code: number, next: number): boolean { + var target = code; + if (target == $MINUS) { + target = next; + } + + return ($a <= target && target <= $z) || ($A <= target && target <= $Z) || target == $BACKSLASH || + target == $MINUS || target == $_; +} + +function isIdentifierPart(target: number) { + return ($a <= target && target <= $z) || ($A <= target && target <= $Z) || target == $BACKSLASH || + target == $MINUS || target == $_ || isDigit(target); +} + +function isValidPseudoSelectorCharacter(code: number) { + switch (code) { + case $LPAREN: + case $RPAREN: + return true; + default: + return false; + } +} + +function isValidKeyframeBlockCharacter(code: number) { + return code == $PERCENT; +} + +function isValidAttributeSelectorCharacter(code: number) { + // value^*|$~=something + switch (code) { + case $$: + case $PIPE: + case $CARET: + case $TILDA: + case $STAR: + case $EQ: + return true; + default: + return false; + } +} + +function isValidSelectorCharacter(code: number) { + // selector [ key = value ] + // IDENT C IDENT C IDENT C + // #id, .class, *+~> + // tag:PSEUDO + switch (code) { + case $HASH: + case $PERIOD: + case $TILDA: + case $STAR: + case $PLUS: + case $GT: + case $COLON: + case $PIPE: + case $COMMA: + return true; + default: + return false; + } +} + +function isValidStyleBlockCharacter(code: number) { + // key:value; + // key:calc(something ... ) + switch (code) { + case $HASH: + case $SEMICOLON: + case $COLON: + case $PERCENT: + case $SLASH: + case $BACKSLASH: + case $BANG: + case $PERIOD: + case $LPAREN: + case $RPAREN: + return true; + default: + return false; + } +} + +function isValidMediaQueryRuleCharacter(code: number) { + // (min-width: 7.5em) and (orientation: landscape) + switch (code) { + case $LPAREN: + case $RPAREN: + case $COLON: + case $PERCENT: + case $PERIOD: + return true; + default: + return false; + } +} + +function isValidAtRuleCharacter(code: number) { + // @document url(http://www.w3.org/page?something=on#hash), + switch (code) { + case $LPAREN: + case $RPAREN: + case $COLON: + case $PERCENT: + case $PERIOD: + case $SLASH: + case $BACKSLASH: + case $HASH: + case $EQ: + case $QUESTION: + case $AMPERSAND: + case $STAR: + case $COMMA: + case $MINUS: + case $PLUS: + return true; + default: + return false; + } +} + +function isValidStyleFunctionCharacter(code: number) { + switch (code) { + case $PERIOD: + case $MINUS: + case $PLUS: + case $STAR: + case $SLASH: + case $LPAREN: + case $RPAREN: + case $COMMA: + return true; + default: + return false; + } +} + +function isValidBlockCharacter(code: number) { + // @something { } + // IDENT + return code == $AT; +} + +function isValidCssCharacter(code: number, mode: CssLexerMode): boolean { + switch (mode) { + case CssLexerMode.ALL: + case CssLexerMode.ALL_TRACK_WS: + return true; + + case CssLexerMode.SELECTOR: + return isValidSelectorCharacter(code); + + case CssLexerMode.PSEUDO_SELECTOR: + return isValidPseudoSelectorCharacter(code); + + case CssLexerMode.ATTRIBUTE_SELECTOR: + return isValidAttributeSelectorCharacter(code); + + case CssLexerMode.MEDIA_QUERY: + return isValidMediaQueryRuleCharacter(code); + + case CssLexerMode.AT_RULE_QUERY: + return isValidAtRuleCharacter(code); + + case CssLexerMode.KEYFRAME_BLOCK: + return isValidKeyframeBlockCharacter(code); + + case CssLexerMode.STYLE_BLOCK: + case CssLexerMode.STYLE_VALUE: + return isValidStyleBlockCharacter(code); + + case CssLexerMode.STYLE_CALC_FUNCTION: + return isValidStyleFunctionCharacter(code); + + case CssLexerMode.BLOCK: + return isValidBlockCharacter(code); + + default: + return false; + } +} + +function charCode(input, index): number { + return index >= input.length ? $EOF : StringWrapper.charCodeAt(input, index); +} + +function charStr(code: number): string { + return StringWrapper.fromCharCode(code); +} + +export function isNewline(code): boolean { + switch (code) { + case $FF: + case $CR: + case $LF: + case $VTAB: + return true; + + default: + return false; + } +} diff --git a/modules/angular2/src/compiler/css/parser.ts b/modules/angular2/src/compiler/css/parser.ts new file mode 100644 index 0000000000..9bfad5ac85 --- /dev/null +++ b/modules/angular2/src/compiler/css/parser.ts @@ -0,0 +1,721 @@ +import { + ParseSourceSpan, + ParseSourceFile, + ParseLocation, + ParseError +} from "angular2/src/compiler/parse_util"; + +import { + bitWiseOr, + bitWiseAnd, + NumberWrapper, + StringWrapper, + isPresent +} from "angular2/src/facade/lang"; + +import { + CssLexerMode, + CssToken, + CssTokenType, + CssScanner, + CssScannerError, + generateErrorMessage, + $AT, + $EOF, + $RBRACE, + $LBRACE, + $LBRACKET, + $RBRACKET, + $LPAREN, + $RPAREN, + $COMMA, + $COLON, + $SEMICOLON, + isNewline +} from "angular2/src/compiler/css/lexer"; + +export {CssToken} from "angular2/src/compiler/css/lexer"; + +export enum BlockType { + Import, + Charset, + Namespace, + Supports, + Keyframes, + MediaQuery, + Selector, + FontFace, + Page, + Document, + Viewport, + Unsupported +} + +const EOF_DELIM = 1; +const RBRACE_DELIM = 2; +const LBRACE_DELIM = 4; +const COMMA_DELIM = 8; +const COLON_DELIM = 16; +const SEMICOLON_DELIM = 32; +const NEWLINE_DELIM = 64; +const RPAREN_DELIM = 128; + +function mergeTokens(tokens: CssToken[], separator: string = ""): CssToken { + var mainToken = tokens[0]; + var str = mainToken.strValue; + for (var i = 1; i < tokens.length; i++) { + str += separator + tokens[i].strValue; + } + + return new CssToken(mainToken.index, mainToken.column, mainToken.line, mainToken.type, str); +} + +function getDelimFromToken(token: CssToken): number { + return getDelimFromCharacter(token.numValue); +} + +function getDelimFromCharacter(code: number): number { + switch (code) { + case $EOF: + return EOF_DELIM; + case $COMMA: + return COMMA_DELIM; + case $COLON: + return COLON_DELIM; + case $SEMICOLON: + return SEMICOLON_DELIM; + case $RBRACE: + return RBRACE_DELIM; + case $LBRACE: + return LBRACE_DELIM; + case $RPAREN: + return RPAREN_DELIM; + default: + return isNewline(code) ? NEWLINE_DELIM : 0; + } +} + +function characterContainsDelimiter(code: number, delimiters: number) { + return bitWiseAnd([getDelimFromCharacter(code), delimiters]) > 0; +} + +export class CssAST { + visit(visitor: CssASTVisitor, context?: any): void {} +} + +export interface CssASTVisitor { + visitCssValue(ast: CssStyleValueAST, context?: any): void; + visitInlineCssRule(ast: CssInlineRuleAST, context?: any): void; + visitCssKeyframeRule(ast: CssKeyframeRuleAST, context?: any): void; + visitCssKeyframeDefinition(ast: CssKeyframeDefinitionAST, context?: any): void; + visitCssMediaQueryRule(ast: CssMediaQueryRuleAST, context?: any): void; + visitCssSelectorRule(ast: CssSelectorRuleAST, context?: any): void; + visitCssSelector(ast: CssSelectorAST, context?: any): void; + visitCssDefinition(ast: CssDefinitionAST, context?: any): void; + visitCssBlock(ast: CssBlockAST, context?: any): void; + visitCssStyleSheet(ast: CssStyleSheetAST, context?: any): void; + visitUnkownRule(ast: CssUnknownTokenListAST, context?: any): void; +} + +export class ParsedCssResult { + constructor(public errors: CssParseError[], public ast: CssStyleSheetAST) {} +} + +export class CssParser { + private _errors: CssParseError[] = []; + private _file: ParseSourceFile; + + constructor(private _scanner: CssScanner, private _fileName: string) { + this._file = new ParseSourceFile(this._scanner.input, _fileName); + } + + _resolveBlockType(token: CssToken): BlockType { + switch (token.strValue) { + case '@-o-keyframes': + case '@-moz-keyframes': + case '@-webkit-keyframes': + case '@keyframes': + return BlockType.Keyframes; + + case '@charset': + return BlockType.Charset; + + case '@import': + return BlockType.Import; + + case '@namespace': + return BlockType.Namespace; + + case '@page': + return BlockType.Page; + + case '@document': + return BlockType.Document; + + case '@media': + return BlockType.MediaQuery; + + case '@font-face': + return BlockType.FontFace; + + case '@viewport': + return BlockType.Viewport; + + case '@supports': + return BlockType.Supports; + + default: + return BlockType.Unsupported; + } + } + + parse(): ParsedCssResult { + var delimiters: number = EOF_DELIM; + var ast = this._parseStyleSheet(delimiters); + + var errors = this._errors; + this._errors = []; + + return new ParsedCssResult(errors, ast); + } + + _parseStyleSheet(delimiters): CssStyleSheetAST { + var results = []; + this._scanner.consumeEmptyStatements(); + while (this._scanner.peek != $EOF) { + this._scanner.setMode(CssLexerMode.BLOCK); + results.push(this._parseRule(delimiters)); + } + return new CssStyleSheetAST(results); + } + + _parseRule(delimiters: number): CssRuleAST { + if (this._scanner.peek == $AT) { + return this._parseAtRule(delimiters); + } + return this._parseSelectorRule(delimiters); + } + + _parseAtRule(delimiters: number): CssRuleAST { + this._scanner.setMode(CssLexerMode.BLOCK); + + var token = this._scan(); + + this._assertCondition(token.type == CssTokenType.AtKeyword, + `The CSS Rule ${token.strValue} is not a valid [@] rule.`, token); + + var block, type = this._resolveBlockType(token); + switch (type) { + case BlockType.Charset: + case BlockType.Namespace: + case BlockType.Import: + var value = this._parseValue(delimiters); + this._scanner.setMode(CssLexerMode.BLOCK); + this._scanner.consumeEmptyStatements(); + return new CssInlineRuleAST(type, value); + + case BlockType.Viewport: + case BlockType.FontFace: + block = this._parseStyleBlock(delimiters); + return new CssBlockRuleAST(type, block); + + case BlockType.Keyframes: + var tokens = this._collectUntilDelim(bitWiseOr([delimiters, RBRACE_DELIM, LBRACE_DELIM])); + // keyframes only have one identifier name + var name = tokens[0]; + return new CssKeyframeRuleAST(name, this._parseKeyframeBlock(delimiters)); + + case BlockType.MediaQuery: + this._scanner.setMode(CssLexerMode.MEDIA_QUERY); + var tokens = this._collectUntilDelim(bitWiseOr([delimiters, RBRACE_DELIM, LBRACE_DELIM])); + return new CssMediaQueryRuleAST(tokens, this._parseBlock(delimiters)); + + case BlockType.Document: + case BlockType.Supports: + case BlockType.Page: + this._scanner.setMode(CssLexerMode.AT_RULE_QUERY); + var tokens = this._collectUntilDelim(bitWiseOr([delimiters, RBRACE_DELIM, LBRACE_DELIM])); + return new CssBlockDefinitionRuleAST(type, tokens, this._parseBlock(delimiters)); + + // if a custom @rule { ... } is used it should still tokenize the insides + default: + var listOfTokens = []; + this._scanner.setMode(CssLexerMode.ALL); + this._error(generateErrorMessage( + this._scanner.input, + `The CSS "at" rule "${token.strValue}" is not allowed to used here`, + token.strValue, token.index, token.line, token.column), + token); + + this._collectUntilDelim(bitWiseOr([delimiters, LBRACE_DELIM, SEMICOLON_DELIM])) + .forEach((token) => { listOfTokens.push(token); }); + if (this._scanner.peek == $LBRACE) { + this._consume(CssTokenType.Character, '{'); + this._collectUntilDelim(bitWiseOr([delimiters, RBRACE_DELIM, LBRACE_DELIM])) + .forEach((token) => { listOfTokens.push(token); }); + this._consume(CssTokenType.Character, '}'); + } + return new CssUnknownTokenListAST(token, listOfTokens); + } + } + + _parseSelectorRule(delimiters: number): CssSelectorRuleAST { + var selectors = this._parseSelectors(delimiters); + var block = this._parseStyleBlock(delimiters); + this._scanner.setMode(CssLexerMode.BLOCK); + this._scanner.consumeEmptyStatements(); + return new CssSelectorRuleAST(selectors, block); + } + + _parseSelectors(delimiters: number): CssSelectorAST[] { + delimiters = bitWiseOr([delimiters, LBRACE_DELIM]); + + var selectors = []; + var isParsingSelectors = true; + while (isParsingSelectors) { + selectors.push(this._parseSelector(delimiters)); + + isParsingSelectors = !characterContainsDelimiter(this._scanner.peek, delimiters); + + if (isParsingSelectors) { + this._consume(CssTokenType.Character, ','); + isParsingSelectors = !characterContainsDelimiter(this._scanner.peek, delimiters); + } + } + + return selectors; + } + + _scan(): CssToken { + var output = this._scanner.scan(); + var token = output.token; + var error = output.error; + if (isPresent(error)) { + this._error(error.rawMessage, token); + } + return token; + } + + _consume(type: CssTokenType, value: string = null): CssToken { + var output = this._scanner.consume(type, value); + var token = output.token; + var error = output.error; + if (isPresent(error)) { + this._error(error.rawMessage, token); + } + return token; + } + + _parseKeyframeBlock(delimiters: number): CssBlockAST { + delimiters = bitWiseOr([delimiters, RBRACE_DELIM]); + this._scanner.setMode(CssLexerMode.KEYFRAME_BLOCK); + + this._consume(CssTokenType.Character, '{'); + + var definitions = []; + while (!characterContainsDelimiter(this._scanner.peek, delimiters)) { + definitions.push(this._parseKeyframeDefinition(delimiters)); + } + + this._consume(CssTokenType.Character, '}'); + + return new CssBlockAST(definitions); + } + + _parseKeyframeDefinition(delimiters: number): CssKeyframeDefinitionAST { + var stepTokens = []; + delimiters = bitWiseOr([delimiters, LBRACE_DELIM]); + while (!characterContainsDelimiter(this._scanner.peek, delimiters)) { + stepTokens.push(this._parseKeyframeLabel(bitWiseOr([delimiters, COMMA_DELIM]))); + if (this._scanner.peek != $LBRACE) { + this._consume(CssTokenType.Character, ','); + } + } + var styles = this._parseStyleBlock(bitWiseOr([delimiters, RBRACE_DELIM])); + this._scanner.setMode(CssLexerMode.BLOCK); + return new CssKeyframeDefinitionAST(stepTokens, styles); + } + + _parseKeyframeLabel(delimiters: number): CssToken { + this._scanner.setMode(CssLexerMode.KEYFRAME_BLOCK); + return mergeTokens(this._collectUntilDelim(delimiters)); + } + + _parseSelector(delimiters: number): CssSelectorAST { + delimiters = bitWiseOr([delimiters, COMMA_DELIM, LBRACE_DELIM]); + this._scanner.setMode(CssLexerMode.SELECTOR); + + var selectorCssTokens = []; + var isComplex = false; + var wsCssToken; + + var previousToken; + var parenCount = 0; + while (!characterContainsDelimiter(this._scanner.peek, delimiters)) { + var code = this._scanner.peek; + switch (code) { + case $LPAREN: + parenCount++; + break; + + case $RPAREN: + parenCount--; + break; + + case $COLON: + this._scanner.setMode(CssLexerMode.PSEUDO_SELECTOR); + previousToken = this._consume(CssTokenType.Character, ':'); + selectorCssTokens.push(previousToken); + continue; + + case $LBRACKET: + // if we are already inside an attribute selector then we can't + // jump into the mode again. Therefore this error will get picked + // up when the scan method is called below. + if (this._scanner.getMode() != CssLexerMode.ATTRIBUTE_SELECTOR) { + selectorCssTokens.push(this._consume(CssTokenType.Character, '[')); + this._scanner.setMode(CssLexerMode.ATTRIBUTE_SELECTOR); + continue; + } + break; + + case $RBRACKET: + selectorCssTokens.push(this._consume(CssTokenType.Character, ']')); + this._scanner.setMode(CssLexerMode.SELECTOR); + continue; + } + + var token = this._scan(); + + // special case for the ":not(" selector since it + // contains an inner selector that needs to be parsed + // in isolation + if (this._scanner.getMode() == CssLexerMode.PSEUDO_SELECTOR && isPresent(previousToken) && + previousToken.numValue == $COLON && token.strValue == "not" && + this._scanner.peek == $LPAREN) { + selectorCssTokens.push(token); + selectorCssTokens.push(this._consume(CssTokenType.Character, '(')); + + // the inner selector inside of :not(...) can only be one + // CSS selector (no commas allowed) therefore we parse only + // one selector by calling the method below + this._parseSelector(bitWiseOr([delimiters, RPAREN_DELIM])) + .tokens.forEach( + (innerSelectorToken) => { selectorCssTokens.push(innerSelectorToken); }); + + selectorCssTokens.push(this._consume(CssTokenType.Character, ')')); + + continue; + } + + previousToken = token; + + if (token.type == CssTokenType.Whitespace) { + wsCssToken = token; + } else { + if (isPresent(wsCssToken)) { + selectorCssTokens.push(wsCssToken); + wsCssToken = null; + isComplex = true; + } + selectorCssTokens.push(token); + } + } + + if (this._scanner.getMode() == CssLexerMode.ATTRIBUTE_SELECTOR) { + this._error( + `Unbalanced CSS attribute selector at column ${previousToken.line}:${previousToken.column}`, + previousToken); + } else if (parenCount > 0) { + this._error( + `Unbalanced pseudo selector function value at column ${previousToken.line}:${previousToken.column}`, + previousToken); + } + + return new CssSelectorAST(selectorCssTokens, isComplex); + } + + _parseValue(delimiters: number): CssStyleValueAST { + delimiters = bitWiseOr([delimiters, RBRACE_DELIM, SEMICOLON_DELIM, NEWLINE_DELIM]); + + this._scanner.setMode(CssLexerMode.STYLE_VALUE); + + var strValue = ""; + var tokens = []; + var previous: CssToken; + while (!characterContainsDelimiter(this._scanner.peek, delimiters)) { + var token; + if (isPresent(previous) && previous.type == CssTokenType.Identifier && + this._scanner.peek == $LPAREN) { + token = this._consume(CssTokenType.Character, '('); + tokens.push(token); + strValue += token.strValue; + + this._scanner.setMode(CssLexerMode.STYLE_VALUE_FUNCTION); + + token = this._scan(); + tokens.push(token); + strValue += token.strValue; + + this._scanner.setMode(CssLexerMode.STYLE_VALUE); + + token = this._consume(CssTokenType.Character, ')'); + tokens.push(token); + strValue += token.strValue; + } else { + token = this._scan(); + if (token.type != CssTokenType.Whitespace) { + tokens.push(token); + } + strValue += token.strValue; + } + + previous = token; + } + + this._scanner.consumeWhitespace(); + + var code = this._scanner.peek; + if (code == $SEMICOLON) { + this._consume(CssTokenType.Character, ';'); + } else if (code != $RBRACE) { + this._error( + generateErrorMessage(this._scanner.input, + `The CSS key/value definition did not end with a semicolon`, + previous.strValue, previous.index, previous.line, previous.column), + previous); + } + + return new CssStyleValueAST(tokens, strValue); + } + + _collectUntilDelim(delimiters: number, assertType: CssTokenType = null): CssToken[] { + var tokens = []; + while (!characterContainsDelimiter(this._scanner.peek, delimiters)) { + var val = isPresent(assertType) ? this._consume(assertType) : this._scan(); + tokens.push(val); + } + return tokens; + } + + _parseBlock(delimiters: number): CssBlockAST { + delimiters = bitWiseOr([delimiters, RBRACE_DELIM]); + + this._scanner.setMode(CssLexerMode.BLOCK); + + this._consume(CssTokenType.Character, '{'); + this._scanner.consumeEmptyStatements(); + + var results = []; + while (!characterContainsDelimiter(this._scanner.peek, delimiters)) { + results.push(this._parseRule(delimiters)); + } + + this._consume(CssTokenType.Character, '}'); + + this._scanner.setMode(CssLexerMode.BLOCK); + this._scanner.consumeEmptyStatements(); + + return new CssBlockAST(results); + } + + _parseStyleBlock(delimiters: number): CssBlockAST { + delimiters = bitWiseOr([delimiters, RBRACE_DELIM, LBRACE_DELIM]); + + this._scanner.setMode(CssLexerMode.STYLE_BLOCK); + + this._consume(CssTokenType.Character, '{'); + this._scanner.consumeEmptyStatements(); + + var definitions = []; + while (!characterContainsDelimiter(this._scanner.peek, delimiters)) { + definitions.push(this._parseDefinition(delimiters)); + this._scanner.consumeEmptyStatements(); + } + + this._consume(CssTokenType.Character, '}'); + + this._scanner.setMode(CssLexerMode.STYLE_BLOCK); + this._scanner.consumeEmptyStatements(); + + return new CssBlockAST(definitions); + } + + _parseDefinition(delimiters: number): CssDefinitionAST { + this._scanner.setMode(CssLexerMode.STYLE_BLOCK); + + var prop = this._consume(CssTokenType.Identifier); + var parseValue, value = null; + + // the colon value separates the prop from the style. + // there are a few cases as to what could happen if it + // is missing + switch (this._scanner.peek) { + case $COLON: + this._consume(CssTokenType.Character, ':'); + parseValue = true; + break; + + case $SEMICOLON: + case $RBRACE: + case $EOF: + parseValue = false; + break; + + default: + var propStr = [prop.strValue]; + if (this._scanner.peek != $COLON) { + // this will throw the error + var nextValue = this._consume(CssTokenType.Character, ':'); + propStr.push(nextValue.strValue); + + var remainingTokens = this._collectUntilDelim( + bitWiseOr([delimiters, COLON_DELIM, SEMICOLON_DELIM]), CssTokenType.Identifier); + if (remainingTokens.length > 0) { + remainingTokens.forEach((token) => { propStr.push(token.strValue); }); + } + + prop = new CssToken(prop.index, prop.column, prop.line, prop.type, propStr.join(" ")); + } + + // this means we've reached the end of the definition and/or block + if (this._scanner.peek == $COLON) { + this._consume(CssTokenType.Character, ':'); + parseValue = true; + } else { + parseValue = false; + } + break; + } + + if (parseValue) { + value = this._parseValue(delimiters); + } else { + this._error(generateErrorMessage(this._scanner.input, + `The CSS property was not paired with a style value`, + prop.strValue, prop.index, prop.line, prop.column), + prop); + } + + return new CssDefinitionAST(prop, value); + } + + _assertCondition(status: boolean, errorMessage: string, problemToken: CssToken): boolean { + if (!status) { + this._error(errorMessage, problemToken); + return true; + } + return false; + } + + _error(message: string, problemToken: CssToken) { + var length = problemToken.strValue.length; + var error = CssParseError.create(this._file, 0, problemToken.line, problemToken.column, length, + message); + this._errors.push(error); + } +} + +export class CssStyleValueAST extends CssAST { + constructor(public tokens: CssToken[], public strValue: string) { super(); } + visit(visitor: CssASTVisitor, context?: any) { visitor.visitCssValue(this); } +} + +export class CssRuleAST extends CssAST {} + +export class CssBlockRuleAST extends CssRuleAST { + constructor(public type: BlockType, public block: CssBlockAST, public name: CssToken = null) { + super(); + } + visit(visitor: CssASTVisitor, context?: any) { visitor.visitCssBlock(this.block, context); } +} + +export class CssKeyframeRuleAST extends CssBlockRuleAST { + constructor(name: CssToken, block: CssBlockAST) { super(BlockType.Keyframes, block, name); } + visit(visitor: CssASTVisitor, context?: any) { visitor.visitCssKeyframeRule(this, context); } +} + +export class CssKeyframeDefinitionAST extends CssBlockRuleAST { + public steps; + constructor(_steps: CssToken[], block: CssBlockAST) { + super(BlockType.Keyframes, block, mergeTokens(_steps, ",")); + this.steps = _steps; + } + visit(visitor: CssASTVisitor, context?: any) { + visitor.visitCssKeyframeDefinition(this, context); + } +} + +export class CssBlockDefinitionRuleAST extends CssBlockRuleAST { + public strValue: string; + constructor(type: BlockType, public query: CssToken[], block: CssBlockAST) { + super(type, block); + this.strValue = query.map(token => token.strValue).join(""); + var firstCssToken: CssToken = query[0]; + this.name = new CssToken(firstCssToken.index, firstCssToken.column, firstCssToken.line, + CssTokenType.Identifier, this.strValue); + } + visit(visitor: CssASTVisitor, context?: any) { visitor.visitCssBlock(this.block, context); } +} + +export class CssMediaQueryRuleAST extends CssBlockDefinitionRuleAST { + constructor(query: CssToken[], block: CssBlockAST) { super(BlockType.MediaQuery, query, block); } + visit(visitor: CssASTVisitor, context?: any) { visitor.visitCssMediaQueryRule(this, context); } +} + +export class CssInlineRuleAST extends CssRuleAST { + constructor(public type: BlockType, public value: CssStyleValueAST) { super(); } + visit(visitor: CssASTVisitor, context?: any) { visitor.visitInlineCssRule(this, context); } +} + +export class CssSelectorRuleAST extends CssBlockRuleAST { + public strValue: string; + + constructor(public selectors: CssSelectorAST[], block: CssBlockAST) { + super(BlockType.Selector, block); + this.strValue = selectors.map(selector => selector.strValue).join(","); + } + + visit(visitor: CssASTVisitor, context?: any) { visitor.visitCssSelectorRule(this, context); } +} + +export class CssDefinitionAST extends CssAST { + constructor(public property: CssToken, public value: CssStyleValueAST) { super(); } + visit(visitor: CssASTVisitor, context?: any) { visitor.visitCssDefinition(this, context); } +} + +export class CssSelectorAST extends CssAST { + public strValue; + constructor(public tokens: CssToken[], public isComplex: boolean = false) { + super(); + this.strValue = tokens.map(token => token.strValue).join(""); + } + visit(visitor: CssASTVisitor, context?: any) { visitor.visitCssSelector(this, context); } +} + +export class CssBlockAST extends CssAST { + constructor(public entries: CssAST[]) { super(); } + visit(visitor: CssASTVisitor, context?: any) { visitor.visitCssBlock(this, context); } +} + +export class CssStyleSheetAST extends CssAST { + constructor(public rules: CssAST[]) { super(); } + visit(visitor: CssASTVisitor, context?: any) { visitor.visitCssStyleSheet(this, context); } +} + +export class CssParseError extends ParseError { + static create(file: ParseSourceFile, offset: number, line: number, col: number, length: number, + errMsg: string): CssParseError { + var start = new ParseLocation(file, offset, line, col); + var end = new ParseLocation(file, offset, line, col + length); + var span = new ParseSourceSpan(start, end); + return new CssParseError(span, "CSS Parse Error: " + errMsg); + } + + constructor(span: ParseSourceSpan, message: string) { super(span, message); } +} + +export class CssUnknownTokenListAST extends CssRuleAST { + constructor(public name, public tokens: CssToken[]) { super(); } + visit(visitor: CssASTVisitor, context?: any) { visitor.visitUnkownRule(this, context); } +} diff --git a/modules/angular2/src/core/change_detection/parser/lexer.ts b/modules/angular2/src/core/change_detection/parser/lexer.ts index dcfdb3f0f6..4d81008dd4 100644 --- a/modules/angular2/src/core/change_detection/parser/lexer.ts +++ b/modules/angular2/src/core/change_detection/parser/lexer.ts @@ -153,7 +153,6 @@ export const $BAR = 124; export const $RBRACE = 125; const $NBSP = 160; - export class ScannerError extends BaseException { constructor(public message) { super(); } diff --git a/modules/angular2/src/facade/lang.dart b/modules/angular2/src/facade/lang.dart index b7ed18ddfc..2822c27433 100644 --- a/modules/angular2/src/facade/lang.dart +++ b/modules/angular2/src/facade/lang.dart @@ -362,3 +362,13 @@ dynamic evalExpression(String sourceUrl, String expr, String declarations, Map (a as int) | (b as int)); + return val as num; +} + +num bitWiseAnd(List values) { + var val = values.reduce((num a, num b) => (a as int) & (b as int)); + return val as num; +} diff --git a/modules/angular2/src/facade/lang.ts b/modules/angular2/src/facade/lang.ts index 8cc1c835ac..8d409f7776 100644 --- a/modules/angular2/src/facade/lang.ts +++ b/modules/angular2/src/facade/lang.ts @@ -473,3 +473,11 @@ export function isPrimitive(obj: any): boolean { export function hasConstructor(value: Object, type: Type): boolean { return value.constructor === type; } + +export function bitWiseOr(values: number[]): number { + return values.reduce((a, b) => { return a | b; }); +} + +export function bitWiseAnd(values: number[]): number { + return values.reduce((a, b) => { return a & b; }); +} diff --git a/modules/angular2/test/compiler/css/lexer_spec.ts b/modules/angular2/test/compiler/css/lexer_spec.ts new file mode 100644 index 0000000000..ff3c01f326 --- /dev/null +++ b/modules/angular2/test/compiler/css/lexer_spec.ts @@ -0,0 +1,393 @@ +import { + ddescribe, + describe, + it, + iit, + xit, + expect, + beforeEach, + afterEach +} from 'angular2/testing_internal'; + +import {isPresent} from "angular2/src/facade/lang"; + +import { + CssToken, + CssScannerError, + CssLexer, + CssLexerMode, + CssTokenType +} from 'angular2/src/compiler/css/lexer'; + +export function main() { + function tokenize(code, trackComments: boolean = false, + mode: CssLexerMode = CssLexerMode.ALL): CssToken[] { + var scanner = new CssLexer().scan(code, trackComments); + scanner.setMode(mode); + + var tokens = []; + var output = scanner.scan(); + while (output != null) { + var error = output.error; + if (isPresent(error)) { + throw new CssScannerError(error.token, error.rawMessage); + } + tokens.push(output.token); + output = scanner.scan(); + } + + return tokens; + } + + describe('CssLexer', () => { + it('should lex newline characters as whitespace when whitespace mode is on', () => { + var newlines = ["\n", "\r\n", "\r", "\f"]; + newlines.forEach((line) => { + var token = tokenize(line, false, CssLexerMode.ALL_TRACK_WS)[0]; + expect(token.type).toEqual(CssTokenType.Whitespace); + }); + }); + + it('should combined newline characters as one newline token when whitespace mode is on', () => { + var newlines = ["\n", "\r\n", "\r", "\f"].join(""); + var tokens = tokenize(newlines, false, CssLexerMode.ALL_TRACK_WS); + expect(tokens.length).toEqual(1); + expect(tokens[0].type).toEqual(CssTokenType.Whitespace); + }); + + it('should not consider whitespace or newline values at all when whitespace mode is off', + () => { + var newlines = ["\n", "\r\n", "\r", "\f"].join(""); + var tokens = tokenize(newlines); + expect(tokens.length).toEqual(0); + }); + + it('should lex simple selectors and their inner properties', () => { + var cssCode = "\n" + " .selector { my-prop: my-value; }\n"; + var tokens = tokenize(cssCode); + + expect(tokens[0].type).toEqual(CssTokenType.Character); + expect(tokens[0].strValue).toEqual('.'); + + expect(tokens[1].type).toEqual(CssTokenType.Identifier); + expect(tokens[1].strValue).toEqual('selector'); + + expect(tokens[2].type).toEqual(CssTokenType.Character); + expect(tokens[2].strValue).toEqual('{'); + + expect(tokens[3].type).toEqual(CssTokenType.Identifier); + expect(tokens[3].strValue).toEqual('my-prop'); + + expect(tokens[4].type).toEqual(CssTokenType.Character); + expect(tokens[4].strValue).toEqual(':'); + + expect(tokens[5].type).toEqual(CssTokenType.Identifier); + expect(tokens[5].strValue).toEqual('my-value'); + + expect(tokens[6].type).toEqual(CssTokenType.Character); + expect(tokens[6].strValue).toEqual(';'); + + expect(tokens[7].type).toEqual(CssTokenType.Character); + expect(tokens[7].strValue).toEqual('}'); + }); + + it('should capture the column and line values for each token', () => { + var cssCode = "#id {\n" + " prop:value;\n" + "}"; + + var tokens = tokenize(cssCode); + + // # + expect(tokens[0].type).toEqual(CssTokenType.Character); + expect(tokens[0].column).toEqual(0); + expect(tokens[0].line).toEqual(0); + + // id + expect(tokens[1].type).toEqual(CssTokenType.Identifier); + expect(tokens[1].column).toEqual(1); + expect(tokens[1].line).toEqual(0); + + // { + expect(tokens[2].type).toEqual(CssTokenType.Character); + expect(tokens[2].column).toEqual(4); + expect(tokens[2].line).toEqual(0); + + // prop + expect(tokens[3].type).toEqual(CssTokenType.Identifier); + expect(tokens[3].column).toEqual(2); + expect(tokens[3].line).toEqual(1); + + // : + expect(tokens[4].type).toEqual(CssTokenType.Character); + expect(tokens[4].column).toEqual(6); + expect(tokens[4].line).toEqual(1); + + // value + expect(tokens[5].type).toEqual(CssTokenType.Identifier); + expect(tokens[5].column).toEqual(7); + expect(tokens[5].line).toEqual(1); + + // ; + expect(tokens[6].type).toEqual(CssTokenType.Character); + expect(tokens[6].column).toEqual(12); + expect(tokens[6].line).toEqual(1); + + // } + expect(tokens[7].type).toEqual(CssTokenType.Character); + expect(tokens[7].column).toEqual(0); + expect(tokens[7].line).toEqual(2); + }); + + it('should lex quoted strings and escape accordingly', () => { + var cssCode = "prop: 'some { value } \\' that is quoted'"; + var tokens = tokenize(cssCode); + + expect(tokens[0].type).toEqual(CssTokenType.Identifier); + expect(tokens[1].type).toEqual(CssTokenType.Character); + expect(tokens[2].type).toEqual(CssTokenType.String); + expect(tokens[2].strValue).toEqual("'some { value } \\' that is quoted'"); + }); + + it('should treat attribute operators as regular characters', () => { + tokenize('^|~+*').forEach((token) => { expect(token.type).toEqual(CssTokenType.Character); }); + }); + + it('should lex numbers properly and set them as numbers', () => { + var cssCode = "0 1 -2 3.0 -4.001"; + var tokens = tokenize(cssCode); + + expect(tokens[0].type).toEqual(CssTokenType.Number); + expect(tokens[0].strValue).toEqual("0"); + + expect(tokens[1].type).toEqual(CssTokenType.Number); + expect(tokens[1].strValue).toEqual("1"); + + expect(tokens[2].type).toEqual(CssTokenType.Number); + expect(tokens[2].strValue).toEqual("-2"); + + expect(tokens[3].type).toEqual(CssTokenType.Number); + expect(tokens[3].strValue).toEqual("3.0"); + + expect(tokens[4].type).toEqual(CssTokenType.Number); + expect(tokens[4].strValue).toEqual("-4.001"); + }); + + it('should lex @keywords', () => { + var cssCode = "@import()@something"; + var tokens = tokenize(cssCode); + + expect(tokens[0].type).toEqual(CssTokenType.AtKeyword); + expect(tokens[0].strValue).toEqual('@import'); + + expect(tokens[1].type).toEqual(CssTokenType.Character); + expect(tokens[1].strValue).toEqual('('); + + expect(tokens[2].type).toEqual(CssTokenType.Character); + expect(tokens[2].strValue).toEqual(')'); + + expect(tokens[3].type).toEqual(CssTokenType.AtKeyword); + expect(tokens[3].strValue).toEqual('@something'); + }); + + it('should still lex a number even if it has a dimension suffix', () => { + var cssCode = "40% is 40 percent"; + var tokens = tokenize(cssCode); + + expect(tokens[0].type).toEqual(CssTokenType.Number); + expect(tokens[0].strValue).toEqual('40'); + + expect(tokens[1].type).toEqual(CssTokenType.Character); + expect(tokens[1].strValue).toEqual('%'); + + expect(tokens[2].type).toEqual(CssTokenType.Identifier); + expect(tokens[2].strValue).toEqual('is'); + + expect(tokens[3].type).toEqual(CssTokenType.Number); + expect(tokens[3].strValue).toEqual('40'); + }); + + it('should allow escaped character and unicode character-strings in CSS selectors', () => { + var cssCode = "\\123456 .some\\thing \{\}"; + var tokens = tokenize(cssCode); + + expect(tokens[0].type).toEqual(CssTokenType.Identifier); + expect(tokens[0].strValue).toEqual('\\123456'); + + expect(tokens[1].type).toEqual(CssTokenType.Character); + expect(tokens[2].type).toEqual(CssTokenType.Identifier); + expect(tokens[2].strValue).toEqual('some\\thing'); + }); + + it('should distinguish identifiers and numbers from special characters', () => { + var cssCode = "one*two=-4+three-4-equals_value$"; + var tokens = tokenize(cssCode); + + expect(tokens[0].type).toEqual(CssTokenType.Identifier); + expect(tokens[0].strValue).toEqual("one"); + + expect(tokens[1].type).toEqual(CssTokenType.Character); + expect(tokens[1].strValue).toEqual("*"); + + expect(tokens[2].type).toEqual(CssTokenType.Identifier); + expect(tokens[2].strValue).toEqual("two"); + + expect(tokens[3].type).toEqual(CssTokenType.Character); + expect(tokens[3].strValue).toEqual("="); + + expect(tokens[4].type).toEqual(CssTokenType.Number); + expect(tokens[4].strValue).toEqual("-4"); + + expect(tokens[5].type).toEqual(CssTokenType.Character); + expect(tokens[5].strValue).toEqual("+"); + + expect(tokens[6].type).toEqual(CssTokenType.Identifier); + expect(tokens[6].strValue).toEqual("three-4-equals_value"); + + expect(tokens[7].type).toEqual(CssTokenType.Character); + expect(tokens[7].strValue).toEqual("$"); + }); + + it('should filter out comments and whitespace by default', () => { + var cssCode = ".selector /* comment */ { /* value */ }"; + var tokens = tokenize(cssCode); + + expect(tokens[0].strValue).toEqual("."); + expect(tokens[1].strValue).toEqual("selector"); + expect(tokens[2].strValue).toEqual("{"); + expect(tokens[3].strValue).toEqual("}"); + }); + + it('should track comments when the flag is set to true', () => { + var cssCode = ".selector /* comment */ { /* value */ }"; + var trackComments = true; + var tokens = tokenize(cssCode, trackComments, CssLexerMode.ALL_TRACK_WS); + + expect(tokens[0].strValue).toEqual("."); + expect(tokens[1].strValue).toEqual("selector"); + expect(tokens[2].strValue).toEqual(" "); + + expect(tokens[3].type).toEqual(CssTokenType.Comment); + expect(tokens[3].strValue).toEqual("/* comment */"); + + expect(tokens[4].strValue).toEqual(" "); + expect(tokens[5].strValue).toEqual("{"); + expect(tokens[6].strValue).toEqual(" "); + + expect(tokens[7].type).toEqual(CssTokenType.Comment); + expect(tokens[7].strValue).toEqual("/* value */"); + }); + + describe('Selector Mode', () => { + it('should throw an error if a selector is being parsed while in the wrong mode', () => { + var cssCode = ".class > tag"; + + var capturedMessage; + try { + tokenize(cssCode, false, CssLexerMode.STYLE_BLOCK); + } catch (e) { + capturedMessage = e.rawMessage; + } + + expect(capturedMessage) + .toMatchPattern(/Unexpected character \[\>\] at column 0:7 in expression/g); + capturedMessage = null; + + try { + tokenize(cssCode, false, CssLexerMode.SELECTOR); + } catch (e) { + capturedMessage = e.rawMessage; + } + + expect(capturedMessage).toEqual(null); + }); + }); + + describe('Attribute Mode', () => { + it('should consider attribute selectors as valid input and throw when an invalid modifier is used', + () => { + function tokenizeAttr(modifier) { + var cssCode = "value" + modifier + "='something'"; + return tokenize(cssCode, false, CssLexerMode.ATTRIBUTE_SELECTOR); + } + + expect(tokenizeAttr("*").length).toEqual(4); + expect(tokenizeAttr("|").length).toEqual(4); + expect(tokenizeAttr("^").length).toEqual(4); + expect(tokenizeAttr("$").length).toEqual(4); + expect(tokenizeAttr("~").length).toEqual(4); + expect(tokenizeAttr("").length).toEqual(3); + + expect(() => { tokenizeAttr("+"); }).toThrow(); + }); + }); + + describe('Media Query Mode', () => { + it('should validate media queries with a reduced subset of valid characters', () => { + function tokenizeQuery(code) { return tokenize(code, false, CssLexerMode.MEDIA_QUERY); } + + // the reason why the numbers are so high is because MediaQueries keep + // track of the whitespace values + expect(tokenizeQuery("(prop: value)").length).toEqual(5); + expect(tokenizeQuery("(prop: value) and (prop2: value2)").length).toEqual(11); + expect(tokenizeQuery("tv and (prop: value)").length).toEqual(7); + expect(tokenizeQuery("print and ((prop: value) or (prop2: value2))").length).toEqual(15); + expect(tokenizeQuery("(content: 'something $ crazy inside &')").length).toEqual(5); + + expect(() => { tokenizeQuery("(max-height: 10 + 20)"); }).toThrow(); + + expect(() => { tokenizeQuery("(max-height: fifty < 100)"); }).toThrow(); + }); + }); + + describe('Pseudo Selector Mode', () => { + it('should validate pseudo selector identifiers with a reduced subset of valid characters', + () => { + function tokenizePseudo(code) { + return tokenize(code, false, CssLexerMode.PSEUDO_SELECTOR); + } + + expect(tokenizePseudo("lang(en-us)").length).toEqual(4); + expect(tokenizePseudo("hover").length).toEqual(1); + expect(tokenizePseudo("focus").length).toEqual(1); + + expect(() => { tokenizePseudo("lang(something:broken)"); }).toThrow(); + + expect(() => { tokenizePseudo("not(.selector)"); }).toThrow(); + }); + }); + + describe('Pseudo Selector Mode', () => { + it('should validate pseudo selector identifiers with a reduced subset of valid characters', + () => { + function tokenizePseudo(code) { + return tokenize(code, false, CssLexerMode.PSEUDO_SELECTOR); + } + + expect(tokenizePseudo("lang(en-us)").length).toEqual(4); + expect(tokenizePseudo("hover").length).toEqual(1); + expect(tokenizePseudo("focus").length).toEqual(1); + + expect(() => { tokenizePseudo("lang(something:broken)"); }).toThrow(); + + expect(() => { tokenizePseudo("not(.selector)"); }).toThrow(); + }); + }); + + describe('Style Block Mode', () => { + it('should style blocks with a reduced subset of valid characters', () => { + function tokenizeStyles(code) { return tokenize(code, false, CssLexerMode.STYLE_BLOCK); } + + expect(tokenizeStyles(` + key: value; + prop: 100; + style: value3!important; + `).length) + .toEqual(14); + + expect(() => tokenizeStyles(` key$: value; `)).toThrow(); + expect(() => tokenizeStyles(` key: value$; `)).toThrow(); + expect(() => tokenizeStyles(` key: value + 10; `)).toThrow(); + expect(() => tokenizeStyles(` key: &value; `)).toThrow(); + }); + }); + }); +} diff --git a/modules/angular2/test/compiler/css/parser_spec.ts b/modules/angular2/test/compiler/css/parser_spec.ts new file mode 100644 index 0000000000..33bc82824e --- /dev/null +++ b/modules/angular2/test/compiler/css/parser_spec.ts @@ -0,0 +1,640 @@ +import { + ddescribe, + describe, + it, + iit, + xit, + expect, + beforeEach, + afterEach +} from 'angular2/testing_internal'; + +import {BaseException} from 'angular2/src/facade/exceptions'; + +import { + ParsedCssResult, + CssParser, + BlockType, + CssSelectorRuleAST, + CssKeyframeRuleAST, + CssKeyframeDefinitionAST, + CssBlockDefinitionRuleAST, + CssMediaQueryRuleAST, + CssBlockRuleAST, + CssInlineRuleAST, + CssStyleValueAST, + CssSelectorAST, + CssDefinitionAST, + CssStyleSheetAST, + CssRuleAST, + CssBlockAST, + CssParseError +} from 'angular2/src/compiler/css/parser'; + +import {CssLexer} from 'angular2/src/compiler/css/lexer'; + +export function assertTokens(tokens, valuesArr) { + for (var i = 0; i < tokens.length; i++) { + expect(tokens[i].strValue == valuesArr[i]); + } +} + +export function main() { + describe('CssParser', () => { + function parse(css): ParsedCssResult { + var lexer = new CssLexer(); + var scanner = lexer.scan(css); + var parser = new CssParser(scanner, 'some-fake-file-name.css'); + return parser.parse(); + } + + function makeAST(css): CssStyleSheetAST { + var output = parse(css); + var errors = output.errors; + if (errors.length > 0) { + throw new BaseException(errors.map((error: CssParseError) => error.msg).join(', ')); + } + return output.ast; + } + + it('should parse CSS into a stylesheet AST', () => { + var styles = ` + .selector { + prop: value123; + } + `; + + var ast = makeAST(styles); + expect(ast.rules.length).toEqual(1); + + var rule = ast.rules[0]; + var selector = rule.selectors[0]; + expect(selector.strValue).toEqual('.selector'); + + var block: CssBlockAST = rule.block; + expect(block.entries.length).toEqual(1); + + var definition = block.entries[0]; + expect(definition.property.strValue).toEqual('prop'); + + var value = definition.value; + expect(value.tokens[0].strValue).toEqual('value123'); + }); + + it('should parse mutliple CSS selectors sharing the same set of styles', () => { + var styles = ` + .class, #id, tag, [attr], key + value, * value, :-moz-any-link { + prop: value123; + } + `; + + var ast = makeAST(styles); + expect(ast.rules.length).toEqual(1); + + var rule = ast.rules[0]; + expect(rule.selectors.length).toBe(7); + + assertTokens(rule.selectors[0].tokens, [".", "class"]); + assertTokens(rule.selectors[1].tokens, ["#", "id"]); + assertTokens(rule.selectors[2].tokens, ["tag"]); + assertTokens(rule.selectors[3].tokens, ["[", "attr", "]"]); + assertTokens(rule.selectors[4].tokens, ["key", " ", "+", " ", "value"]); + assertTokens(rule.selectors[5].tokens, ["*", " ", "value"]); + assertTokens(rule.selectors[6].tokens, [":", "-moz-any-link"]); + + var style1 = rule.block.entries[0]; + expect(style1.property.strValue).toEqual("prop"); + assertTokens(style1.value.tokens, ["value123"]); + }); + + it('should parse keyframe rules', () => { + var styles = ` + @keyframes rotateMe { + from { + transform: rotate(-360deg); + } + 50% { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } + `; + + var ast = makeAST(styles); + expect(ast.rules.length).toEqual(1); + + var rule = ast.rules[0]; + expect(rule.name.strValue).toEqual('rotateMe'); + + var block = rule.block; + var fromRule = block.entries[0]; + + expect(fromRule.name.strValue).toEqual('from'); + var fromStyle = (fromRule.block).entries[0]; + expect(fromStyle.property.strValue).toEqual('transform'); + assertTokens(fromStyle.value.tokens, ['rotate', '(', '-360', 'deg', ')']); + + var midRule = block.entries[1]; + + expect(midRule.name.strValue).toEqual('50%'); + var midStyle = (midRule.block).entries[0]; + expect(midStyle.property.strValue).toEqual('transform'); + assertTokens(midStyle.value.tokens, ['rotate', '(', '0', 'deg', ')']); + + var toRule = block.entries[2]; + + expect(toRule.name.strValue).toEqual('to'); + var toStyle = (toRule.block).entries[0]; + expect(toStyle.property.strValue).toEqual('transform'); + assertTokens(toStyle.value.tokens, ['rotate', '(', '360', 'deg', ')']); + }); + + it('should parse media queries into a stylesheet AST', () => { + var styles = ` + @media all and (max-width:100px) { + .selector { + prop: value123; + } + } + `; + + var ast = makeAST(styles); + expect(ast.rules.length).toEqual(1); + + var rule = ast.rules[0]; + assertTokens(rule.query, ['all', 'and', '(', 'max-width', ':', '100', 'px', ')']); + + var block = rule.block; + expect(block.entries.length).toEqual(1); + + var rule2 = block.entries[0]; + expect(rule2.selectors[0].strValue).toEqual('.selector'); + + var block2 = rule2.block; + expect(block2.entries.length).toEqual(1); + }); + + it('should parse inline CSS values', () => { + var styles = ` + @import url('remote.css'); + @charset "UTF-8"; + @namespace ng url(http://angular.io/namespace/ng); + `; + + var ast = makeAST(styles); + + var importRule = ast.rules[0]; + expect(importRule.type).toEqual(BlockType.Import); + assertTokens(importRule.value.tokens, ["url", "(", "remote", ".", "css", ")"]); + + var charsetRule = ast.rules[1]; + expect(charsetRule.type).toEqual(BlockType.Charset); + assertTokens(charsetRule.value.tokens, ["UTF-8"]); + + var namespaceRule = ast.rules[2]; + expect(namespaceRule.type).toEqual(BlockType.Namespace); + assertTokens(namespaceRule.value.tokens, + ["ng", "url", "(", "http://angular.io/namespace/ng", ")"]); + }); + + it('should parse CSS values that contain functions and leave the inner function data untokenized', + () => { + var styles = ` + .class { + background: url(matias.css); + animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060); + height: calc(100% - 50px); + } + `; + + var ast = makeAST(styles); + expect(ast.rules.length).toEqual(1); + + var defs = (ast.rules[0]).block.entries; + expect(defs.length).toEqual(3); + + assertTokens((defs[0]).value.tokens, ['url', '(', 'matias.css', ')']); + assertTokens((defs[1]).value.tokens, + ['cubic-bezier', '(', '0.755, 0.050, 0.855, 0.060', ')']); + assertTokens((defs[2]).value.tokens, ['calc', '(', '100% - 50px', ')']); + }); + + it('should parse un-named block-level CSS values', () => { + var styles = ` + @font-face { + font-family: "Matias"; + font-weight: bold; + src: url(font-face.ttf); + } + @viewport { + max-width: 100px; + min-height: 1000px; + } + `; + + var ast = makeAST(styles); + + var fontFaceRule = ast.rules[0]; + expect(fontFaceRule.type).toEqual(BlockType.FontFace); + expect(fontFaceRule.block.entries.length).toEqual(3); + + var viewportRule = ast.rules[1]; + expect(viewportRule.type).toEqual(BlockType.Viewport); + expect(viewportRule.block.entries.length).toEqual(2); + }); + + it('should parse multiple levels of semicolons', () => { + var styles = ` + ;;; + @import url('something something') + ;;;;;;;; + ;;;;;;;; + ;@font-face { + ;src : url(font-face.ttf);;;;;;;; + ;;;-webkit-animation:my-animation + };;; + @media all and (max-width:100px) + {; + .selector {prop: value123;}; + ;.selector2{prop:1}} + `; + + var ast = makeAST(styles); + + var importRule = ast.rules[0]; + expect(importRule.type).toEqual(BlockType.Import); + assertTokens(importRule.value.tokens, ["url", "(", "something something", ")"]); + + var fontFaceRule = ast.rules[1]; + expect(fontFaceRule.type).toEqual(BlockType.FontFace); + expect(fontFaceRule.block.entries.length).toEqual(2); + + var mediaQueryRule = ast.rules[2]; + assertTokens(mediaQueryRule.query, ['all', 'and', '(', 'max-width', ':', '100', 'px', ')']); + expect(mediaQueryRule.block.entries.length).toEqual(2); + }); + + it('should throw an error if an unknown @value block rule is parsed', () => { + var styles = ` + @matias { hello: there; } + `; + + expect(() => { + makeAST(styles); + }).toThrowError(/^CSS Parse Error: The CSS "at" rule "@matias" is not allowed to used here/g); + }); + + it('should parse empty rules', () => { + var styles = ` + .empty-rule { } + .somewhat-empty-rule { /* property: value; */ } + .non-empty-rule { property: value; } + `; + + var ast = makeAST(styles); + + var rules = ast.rules; + expect((rules[0]).block.entries.length).toEqual(0); + expect((rules[1]).block.entries.length).toEqual(0); + expect((rules[2]).block.entries.length).toEqual(1); + }); + + it('should parse the @document rule', () => { + var styles = ` + @document url(http://www.w3.org/), + url-prefix(http://www.w3.org/Style/), + domain(mozilla.org), + regexp("https:.*") + { + /* CSS rules here apply to: + - The page "http://www.w3.org/". + - Any page whose URL begins with "http://www.w3.org/Style/" + - Any page whose URL's host is "mozilla.org" or ends with + ".mozilla.org" + - Any page whose URL starts with "https:" */ + + /* make the above-mentioned pages really ugly */ + body { + color: purple; + background: yellow; + } + } + `; + + var ast = makeAST(styles); + + var rules = ast.rules; + var documentRule = rules[0]; + expect(documentRule.type).toEqual(BlockType.Document); + + var rule = documentRule.block.entries[0]; + expect(rule.strValue).toEqual("body"); + }); + + it('should parse the @page rule', () => { + var styles = ` + @page one { + .selector { prop: value; } + } + @page two { + .selector2 { prop: value2; } + } + `; + + var ast = makeAST(styles); + + var rules = ast.rules; + + var pageRule1 = rules[0]; + expect(pageRule1.strValue).toEqual("one"); + expect(pageRule1.type).toEqual(BlockType.Page); + + var pageRule2 = rules[1]; + expect(pageRule2.strValue).toEqual("two"); + expect(pageRule2.type).toEqual(BlockType.Page); + + var selectorOne = pageRule1.block.entries[0]; + expect(selectorOne.strValue).toEqual('.selector'); + + var selectorTwo = pageRule2.block.entries[0]; + expect(selectorTwo.strValue).toEqual('.selector2'); + }); + + it('should parse the @supports rule', () => { + var styles = ` + @supports (animation-name: "rotate") { + a:hover { animation: rotate 1s; } + } + `; + + var ast = makeAST(styles); + + var rules = ast.rules; + + var supportsRule = rules[0]; + assertTokens(supportsRule.query, ['(', 'animation-name', ':', 'rotate', ')']); + expect(supportsRule.type).toEqual(BlockType.Supports); + + var selectorOne = supportsRule.block.entries[0]; + expect(selectorOne.strValue).toEqual('a:hover'); + }); + + it('should collect multiple errors during parsing', () => { + var styles = ` + .class$value { something: something } + @custom { something: something } + #id { cool^: value } + `; + + var output = parse(styles); + expect(output.errors.length).toEqual(3); + }); + + it('should recover from selector errors and continue parsing', () => { + var styles = ` + tag& { key: value; } + .%tag { key: value; } + #tag$ { key: value; } + `; + + var output = parse(styles); + var errors = output.errors; + var ast = output.ast; + + expect(errors.length).toEqual(3); + + expect(ast.rules.length).toEqual(3); + + var rule1 = ast.rules[0]; + expect(rule1.selectors[0].strValue).toEqual("tag&"); + expect(rule1.block.entries.length).toEqual(1); + + var rule2 = ast.rules[1]; + expect(rule2.selectors[0].strValue).toEqual(".%tag"); + expect(rule2.block.entries.length).toEqual(1); + + var rule3 = ast.rules[2]; + expect(rule3.selectors[0].strValue).toEqual("#tag$"); + expect(rule3.block.entries.length).toEqual(1); + }); + + it('should throw an error when parsing invalid CSS Selectors', () => { + var styles = '.class[[prop%=value}] { style: val; }'; + var output = parse(styles); + var errors = output.errors; + + expect(errors.length).toEqual(3); + + expect(errors[0].msg).toMatchPattern(/Unexpected character \[\[\] at column 0:7/g); + + expect(errors[1].msg).toMatchPattern(/Unexpected character \[%\] at column 0:12/g); + + expect(errors[2].msg).toMatchPattern(/Unexpected character \[}\] at column 0:19/g); + }); + + it('should throw an error if an attribute selector is not closed properly', () => { + var styles = '.class[prop=value { style: val; }'; + var output = parse(styles); + var errors = output.errors; + + expect(errors[0].msg).toMatchPattern(/Unbalanced CSS attribute selector at column 0:12/g); + }); + + it('should throw an error if a pseudo function selector is not closed properly', () => { + var styles = 'body:lang(en { key:value; }'; + var output = parse(styles); + var errors = output.errors; + + expect(errors[0].msg) + .toMatchPattern(/Unbalanced pseudo selector function value at column 0:10/g); + }); + + it('should raise an error when a semi colon is missing from a CSS style/pair that isn\'t the last entry', + () => { + var styles = `.class { + color: red + background: blue + }`; + + var output = parse(styles); + var errors = output.errors; + + expect(errors.length).toEqual(1); + + expect(errors[0].msg) + .toMatchPattern( + /The CSS key\/value definition did not end with a semicolon at column 1:15/g); + }); + + it('should parse the inner value of a :not() pseudo-selector as a CSS selector', () => { + var styles = `div:not(.ignore-this-div) { + prop: value; + }`; + + var output = parse(styles); + var errors = output.errors; + var ast = output.ast; + + expect(errors.length).toEqual(0); + + var rule1 = ast.rules[0]; + expect(rule1.selectors.length).toEqual(1); + + var selector = rule1.selectors[0]; + assertTokens(selector.tokens, ['div', ':', 'not', '(', '.', 'ignore-this-div', ')']); + }); + + it('should raise parse errors when CSS key/value pairs are invalid', () => { + var styles = `.class { + background color: value; + color: value + font-size; + font-weight + }`; + + var output = parse(styles); + var errors = output.errors; + + expect(errors.length).toEqual(4); + + expect(errors[0].msg) + .toMatchPattern( + /Identifier does not match expected Character value \("color" should match ":"\) at column 1:19/g); + + expect(errors[1].msg) + .toMatchPattern( + /The CSS key\/value definition did not end with a semicolon at column 2:15/g); + + expect(errors[2].msg) + .toMatchPattern(/The CSS property was not paired with a style value at column 3:8/g); + + expect(errors[3].msg) + .toMatchPattern(/The CSS property was not paired with a style value at column 4:8/g); + }); + + it('should recover from CSS key/value parse errors', () => { + var styles = ` + .problem-class { background color: red; color: white; } + .good-boy-class { background-color: red; color: white; } + `; + + var output = parse(styles); + var ast = output.ast; + + expect(ast.rules.length).toEqual(2); + + var rule1 = ast.rules[0]; + expect(rule1.block.entries.length).toEqual(2); + + var style1 = rule1.block.entries[0]; + expect(style1.property.strValue).toEqual('background color'); + assertTokens(style1.value.tokens, ['red']); + + var style2 = rule1.block.entries[1]; + expect(style2.property.strValue).toEqual('color'); + assertTokens(style2.value.tokens, ['white']); + }); + + it('should parse minified CSS content properly', () => { + // this code was taken from the angular.io webpage's CSS code + var styles = ` +.is-hidden{display:none!important} +.is-visible{display:block!important} +.is-visually-hidden{height:1px;width:1px;overflow:hidden;opacity:0.01;position:absolute;bottom:0;right:0;z-index:1} +.grid-fluid,.grid-fixed{margin:0 auto} +.grid-fluid .c1,.grid-fixed .c1,.grid-fluid .c2,.grid-fixed .c2,.grid-fluid .c3,.grid-fixed .c3,.grid-fluid .c4,.grid-fixed .c4,.grid-fluid .c5,.grid-fixed .c5,.grid-fluid .c6,.grid-fixed .c6,.grid-fluid .c7,.grid-fixed .c7,.grid-fluid .c8,.grid-fixed .c8,.grid-fluid .c9,.grid-fixed .c9,.grid-fluid .c10,.grid-fixed .c10,.grid-fluid .c11,.grid-fixed .c11,.grid-fluid .c12,.grid-fixed .c12{display:inline;float:left} +.grid-fluid .c1.grid-right,.grid-fixed .c1.grid-right,.grid-fluid .c2.grid-right,.grid-fixed .c2.grid-right,.grid-fluid .c3.grid-right,.grid-fixed .c3.grid-right,.grid-fluid .c4.grid-right,.grid-fixed .c4.grid-right,.grid-fluid .c5.grid-right,.grid-fixed .c5.grid-right,.grid-fluid .c6.grid-right,.grid-fixed .c6.grid-right,.grid-fluid .c7.grid-right,.grid-fixed .c7.grid-right,.grid-fluid .c8.grid-right,.grid-fixed .c8.grid-right,.grid-fluid .c9.grid-right,.grid-fixed .c9.grid-right,.grid-fluid .c10.grid-right,.grid-fixed .c10.grid-right,.grid-fluid .c11.grid-right,.grid-fixed .c11.grid-right,.grid-fluid .c12.grid-right,.grid-fixed .c12.grid-right{float:right} +.grid-fluid .c1.nb,.grid-fixed .c1.nb,.grid-fluid .c2.nb,.grid-fixed .c2.nb,.grid-fluid .c3.nb,.grid-fixed .c3.nb,.grid-fluid .c4.nb,.grid-fixed .c4.nb,.grid-fluid .c5.nb,.grid-fixed .c5.nb,.grid-fluid .c6.nb,.grid-fixed .c6.nb,.grid-fluid .c7.nb,.grid-fixed .c7.nb,.grid-fluid .c8.nb,.grid-fixed .c8.nb,.grid-fluid .c9.nb,.grid-fixed .c9.nb,.grid-fluid .c10.nb,.grid-fixed .c10.nb,.grid-fluid .c11.nb,.grid-fixed .c11.nb,.grid-fluid .c12.nb,.grid-fixed .c12.nb{margin-left:0} +.grid-fluid .c1.na,.grid-fixed .c1.na,.grid-fluid .c2.na,.grid-fixed .c2.na,.grid-fluid .c3.na,.grid-fixed .c3.na,.grid-fluid .c4.na,.grid-fixed .c4.na,.grid-fluid .c5.na,.grid-fixed .c5.na,.grid-fluid .c6.na,.grid-fixed .c6.na,.grid-fluid .c7.na,.grid-fixed .c7.na,.grid-fluid .c8.na,.grid-fixed .c8.na,.grid-fluid .c9.na,.grid-fixed .c9.na,.grid-fluid .c10.na,.grid-fixed .c10.na,.grid-fluid .c11.na,.grid-fixed .c11.na,.grid-fluid .c12.na,.grid-fixed .c12.na{margin-right:0} + `; + + var output = parse(styles); + var errors = output.errors; + expect(errors.length).toEqual(0); + + var ast = output.ast; + expect(ast.rules.length).toEqual(8); + }); + + it('should parse a snippet of keyframe code from animate.css properly', () => { + // this code was taken from the angular.io webpage's CSS code + var styles = ` +@charset "UTF-8"; + +/*! + * animate.css -http://daneden.me/animate + * Version - 3.5.1 + * Licensed under the MIT license - http://opensource.org/licenses/MIT + * + * Copyright (c) 2016 Daniel Eden + */ + +.animated { + -webkit-animation-duration: 1s; + animation-duration: 1s; + -webkit-animation-fill-mode: both; + animation-fill-mode: both; +} + +.animated.infinite { + -webkit-animation-iteration-count: infinite; + animation-iteration-count: infinite; +} + +.animated.hinge { + -webkit-animation-duration: 2s; + animation-duration: 2s; +} + +.animated.flipOutX, +.animated.flipOutY, +.animated.bounceIn, +.animated.bounceOut { + -webkit-animation-duration: .75s; + animation-duration: .75s; +} + +@-webkit-keyframes bounce { + from, 20%, 53%, 80%, to { + -webkit-animation-timing-function: cubic-bezier(0.215, 0.610, 0.355, 1.000); + animation-timing-function: cubic-bezier(0.215, 0.610, 0.355, 1.000); + -webkit-transform: translate3d(0,0,0); + transform: translate3d(0,0,0); + } + + 40%, 43% { + -webkit-animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060); + animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060); + -webkit-transform: translate3d(0, -30px, 0); + transform: translate3d(0, -30px, 0); + } + + 70% { + -webkit-animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060); + animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060); + -webkit-transform: translate3d(0, -15px, 0); + transform: translate3d(0, -15px, 0); + } + + 90% { + -webkit-transform: translate3d(0,-4px,0); + transform: translate3d(0,-4px,0); + } +} + `; + + var output = parse(styles); + var errors = output.errors; + expect(errors.length).toEqual(0); + + var ast = output.ast; + expect(ast.rules.length).toEqual(6); + + var finalRule = ast.rules[ast.rules.length - 1]; + expect(finalRule.type).toEqual(BlockType.Keyframes); + expect(finalRule.block.entries.length).toEqual(4); + }); + }); +} diff --git a/modules/angular2/test/compiler/css/visitor_spec.ts b/modules/angular2/test/compiler/css/visitor_spec.ts new file mode 100644 index 0000000000..d6c9b7696b --- /dev/null +++ b/modules/angular2/test/compiler/css/visitor_spec.ts @@ -0,0 +1,252 @@ +import { + ddescribe, + describe, + it, + iit, + xit, + expect, + beforeEach, + afterEach +} from 'angular2/testing_internal'; + +import {NumberWrapper, StringWrapper, isPresent} from "angular2/src/facade/lang"; +import {BaseException} from 'angular2/src/facade/exceptions'; + +import { + CssToken, + CssParser, + CssParseError, + BlockType, + CssAST, + CssSelectorRuleAST, + CssKeyframeRuleAST, + CssKeyframeDefinitionAST, + CssBlockDefinitionRuleAST, + CssMediaQueryRuleAST, + CssBlockRuleAST, + CssInlineRuleAST, + CssStyleValueAST, + CssSelectorAST, + CssDefinitionAST, + CssStyleSheetAST, + CssRuleAST, + CssBlockAST, + CssASTVisitor, + CssUnknownTokenListAST +} from 'angular2/src/compiler/css/parser'; + +import {CssLexer} from 'angular2/src/compiler/css/lexer'; + +function _assertTokens(tokens, valuesArr) { + for (var i = 0; i < tokens.length; i++) { + expect(tokens[i].strValue == valuesArr[i]); + } +} + +class MyVisitor implements CssASTVisitor { + captures: {[key: string]: any[]} = {}; + + _capture(method, ast, context) { + this.captures[method] = isPresent(this.captures[method]) ? this.captures[method] : []; + this.captures[method].push([ast, context]); + } + + constructor(ast: CssStyleSheetAST, context?: any) { ast.visit(this, context); } + + visitCssValue(ast, context?: any): void { this._capture("visitCssValue", ast, context); } + + visitInlineCssRule(ast, context?: any): void { + this._capture("visitInlineCssRule", ast, context); + } + + visitCssKeyframeRule(ast: CssKeyframeRuleAST, context?: any): void { + this._capture("visitCssKeyframeRule", ast, context); + ast.block.visit(this, context); + } + + visitCssKeyframeDefinition(ast: CssKeyframeDefinitionAST, context?: any): void { + this._capture("visitCssKeyframeDefinition", ast, context); + ast.block.visit(this, context); + } + + visitCssMediaQueryRule(ast: CssMediaQueryRuleAST, context?: any): void { + this._capture("visitCssMediaQueryRule", ast, context); + ast.block.visit(this, context); + } + + visitCssSelectorRule(ast: CssSelectorRuleAST, context?: any): void { + this._capture("visitCssSelectorRule", ast, context); + ast.selectors.forEach((selAST: CssSelectorAST) => { selAST.visit(this, context); }); + ast.block.visit(this, context); + } + + visitCssSelector(ast: CssSelectorAST, context?: any): void { + this._capture("visitCssSelector", ast, context); + } + + visitCssDefinition(ast: CssDefinitionAST, context?: any): void { + this._capture("visitCssDefinition", ast, context); + ast.value.visit(this, context); + } + + visitCssBlock(ast: CssBlockAST, context?: any): void { + this._capture("visitCssBlock", ast, context); + ast.entries.forEach((entryAST: CssAST) => { entryAST.visit(this, context); }); + } + + visitCssStyleSheet(ast: CssStyleSheetAST, context?: any): void { + this._capture("visitCssStyleSheet", ast, context); + ast.rules.forEach((ruleAST: CssRuleAST) => { ruleAST.visit(this, context); }); + } + + visitUnkownRule(ast: CssUnknownTokenListAST, context?: any): void { + // nothing + } +} + +export function main() { + function parse(cssCode: string) { + var lexer = new CssLexer(); + var scanner = lexer.scan(cssCode); + var parser = new CssParser(scanner, 'some-fake-file-name.css'); + var output = parser.parse(); + var errors = output.errors; + if (errors.length > 0) { + throw new BaseException(errors.map((error: CssParseError) => error.msg).join(', ')); + } + return output.ast; + } + + describe('CSS parsing and visiting', () => { + var ast; + var context = {}; + + beforeEach(() => { + var cssCode = ` + .rule1 { prop1: value1 } + .rule2 { prop2: value2 } + + @media all (max-width: 100px) { + #id { prop3 :value3; } + } + + @import url(file.css); + + @keyframes rotate { + from { + prop4: value4; + } + 50%, 100% { + prop5: value5; + } + } + `; + ast = parse(cssCode); + }); + + it('should parse and visit a stylesheet', () => { + var visitor = new MyVisitor(ast, context); + var captures = visitor.captures['visitCssStyleSheet']; + + expect(captures.length).toEqual(1); + + var capture = captures[0]; + expect(capture[0]).toEqual(ast); + expect(capture[1]).toEqual(context); + }); + + it('should parse and visit each of the stylesheet selectors', () => { + var visitor = new MyVisitor(ast, context); + var captures = visitor.captures['visitCssSelectorRule']; + + expect(captures.length).toEqual(3); + + var rule1 = captures[0][0]; + expect(rule1).toEqual(ast.rules[0]); + _assertTokens(rule1.selectors[0].tokens, ['.', 'rule1']); + + var rule2 = captures[1][0]; + expect(rule2).toEqual(ast.rules[1]); + _assertTokens(rule2.selectors[0].tokens, ['.', 'rule2']); + + var rule3 = captures[2][0]; + expect(rule3).toEqual((ast.rules[2]).block.entries[0]); + _assertTokens(rule3.selectors[0].tokens, ['#', 'rule3']); + }); + + it('should parse and visit each of the stylesheet style key/value definitions', () => { + var visitor = new MyVisitor(ast, context); + var captures = visitor.captures['visitCssDefinition']; + + expect(captures.length).toEqual(5); + + var def1 = captures[0][0]; + expect(def1.property.strValue).toEqual('prop1'); + expect(def1.value.tokens[0].strValue).toEqual('value1'); + + var def2 = captures[1][0]; + expect(def2.property.strValue).toEqual('prop2'); + expect(def2.value.tokens[0].strValue).toEqual('value2'); + + var def3 = captures[2][0]; + expect(def3.property.strValue).toEqual('prop3'); + expect(def3.value.tokens[0].strValue).toEqual('value3'); + + var def4 = captures[3][0]; + expect(def4.property.strValue).toEqual('prop4'); + expect(def4.value.tokens[0].strValue).toEqual('value4'); + + var def5 = captures[4][0]; + expect(def5.property.strValue).toEqual('prop5'); + expect(def5.value.tokens[0].strValue).toEqual('value5'); + }); + + it('should parse and visit the associated media query values', () => { + var visitor = new MyVisitor(ast, context); + var captures = visitor.captures['visitCssMediaQueryRule']; + + expect(captures.length).toEqual(1); + + var query1 = captures[0][0]; + _assertTokens(query1.query, ["all", "and", "(", "max-width", "100", "px", ")"]); + expect(query1.block.entries.length).toEqual(1); + }); + + it('should parse and visit the associated "@inline" rule values', () => { + var visitor = new MyVisitor(ast, context); + var captures = visitor.captures['visitInlineCssRule']; + + expect(captures.length).toEqual(1); + + var query1 = captures[0][0]; + expect(query1.type).toEqual(BlockType.Import); + _assertTokens(query1.value.tokens, ['url', '(', 'file.css', ')']); + }); + + it('should parse and visit the keyframe blocks', () => { + var visitor = new MyVisitor(ast, context); + var captures = visitor.captures['visitCssKeyframeRule']; + + expect(captures.length).toEqual(1); + + var keyframe1 = captures[0][0]; + expect(keyframe1.name.strValue).toEqual('rotate'); + expect(keyframe1.block.entries.length).toEqual(2); + }); + + it('should parse and visit the associated keyframe rules', () => { + var visitor = new MyVisitor(ast, context); + var captures = visitor.captures['visitCssKeyframeDefinition']; + + expect(captures.length).toEqual(2); + + var def1 = captures[0][0]; + _assertTokens(def1.steps, ['from']); + expect(def1.block.entries.length).toEqual(1); + + var def2 = captures[1][0]; + _assertTokens(def2.steps, ['50%', '100%']); + expect(def2.block.entries.length).toEqual(1); + }); + }); +}