diff --git a/src/shady-css/common.ts b/src/shady-css/common.ts index 1e1d011..cabd2e5 100644 --- a/src/shady-css/common.ts +++ b/src/shady-css/common.ts @@ -23,7 +23,7 @@ const matcher = { /** * An enumeration of Node types. */ -export enum nodeType { +export const enum nodeType { stylesheet = 'stylesheet', comment = 'comment', atRule = 'atRule', diff --git a/src/shady-css/parser.ts b/src/shady-css/parser.ts index 7b35e3f..037da4d 100644 --- a/src/shady-css/parser.ts +++ b/src/shady-css/parser.ts @@ -11,7 +11,7 @@ import {AtRule, Comment, Declaration, Discarded, Rule, Rulelist, Ruleset, Stylesheet} from './common'; import {NodeFactory} from './node-factory'; -import {Token} from './token'; +import {TokenType} from './token'; import {Tokenizer} from './tokenizer'; /** @@ -81,20 +81,21 @@ class Parser { if (token === null) { return null; } - if (token.is(Token.type.whitespace)) { + if (token.is(TokenType.whitespace)) { tokenizer.advance(); return null; - } else if (token.is(Token.type.comment)) { + } else if (token.is(TokenType.comment)) { return this.parseComment(tokenizer); - } else if (token.is(Token.type.word)) { + } else if (token.is(TokenType.word) || + token.is(TokenType.openBrace)) { return this.parseDeclarationOrRuleset(tokenizer); - } else if (token.is(Token.type.propertyBoundary)) { + } else if (token.is(TokenType.propertyBoundary)) { return this.parseUnknown(tokenizer); - } else if (token.is(Token.type.at)) { + } else if (token.is(TokenType.at)) { return this.parseAtRule(tokenizer); } else { @@ -130,7 +131,7 @@ class Parser { } while (tokenizer.currentToken && - tokenizer.currentToken.is(Token.type.boundary)) { + tokenizer.currentToken.is(TokenType.semicolon)) { end = tokenizer.advance(); } @@ -155,26 +156,28 @@ class Parser { const start = tokenizer.currentToken.start; while (tokenizer.currentToken) { - if (tokenizer.currentToken.is(Token.type.whitespace)) { + if (tokenizer.currentToken.is(TokenType.whitespace)) { tokenizer.advance(); - } else if (!name && tokenizer.currentToken.is(Token.type.at)) { + } else if (!name && tokenizer.currentToken.is(TokenType.at)) { // Discard the @: tokenizer.advance(); const start = tokenizer.currentToken; let end; while (tokenizer.currentToken && - tokenizer.currentToken.is(Token.type.word)) { + tokenizer.currentToken.is(TokenType.word)) { end = tokenizer.advance(); } nameRange = tokenizer.getRange(start, end); name = tokenizer.cssText.slice(nameRange.start, nameRange.end); - } else if (tokenizer.currentToken.is(Token.type.openBrace)) { + } else if (tokenizer.currentToken.is(TokenType.openBrace)) { rulelist = this.parseRulelist(tokenizer); break; - } else if (tokenizer.currentToken.is(Token.type.propertyBoundary)) { + } else if (tokenizer.currentToken.is(TokenType.semicolon)) { tokenizer.advance(); break; + } else if (tokenizer.currentToken.is(TokenType.closeBrace)) { + break; } else { if (parametersStart == null) { parametersStart = tokenizer.advance(); @@ -215,7 +218,7 @@ class Parser { tokenizer.advance(); while (tokenizer.currentToken) { - if (tokenizer.currentToken.is(Token.type.closeBrace)) { + if (tokenizer.currentToken.is(TokenType.closeBrace)) { endToken = tokenizer.currentToken; tokenizer.advance(); break; @@ -239,50 +242,40 @@ class Parser { * @param tokenizer A Tokenizer node. */ parseDeclarationOrRuleset(tokenizer: Tokenizer): Declaration|Ruleset|null { - let ruleStart = null; - let ruleEnd = null; - let colon = null; + if (!tokenizer.currentToken) { + return null; + } - // This code is not obviously correct. e.g. there's what looks to be a - // null-dereference if the declaration starts with an open brace or - // property boundary.. though that may be impossible. + let ruleStart = tokenizer.currentToken; + let ruleEnd = ruleStart.previous; + let colon = null; while (tokenizer.currentToken) { - if (tokenizer.currentToken.is(Token.type.whitespace)) { + if (tokenizer.currentToken.is(TokenType.whitespace)) { tokenizer.advance(); - } else if (tokenizer.currentToken.is(Token.type.openParenthesis)) { + } else if (tokenizer.currentToken.is(TokenType.openParenthesis)) { // skip until close paren while (tokenizer.currentToken && - !tokenizer.currentToken.is(Token.type.closeParenthesis)) { + !tokenizer.currentToken.is(TokenType.closeParenthesis)) { tokenizer.advance(); } } else if ( - tokenizer.currentToken.is(Token.type.openBrace) || - tokenizer.currentToken.is(Token.type.propertyBoundary)) { + tokenizer.currentToken.is(TokenType.openBrace) || + tokenizer.currentToken.is(TokenType.propertyBoundary)) { break; } else { - if (tokenizer.currentToken.is(Token.type.colon)) { + if (tokenizer.currentToken.is(TokenType.colon)) { colon = tokenizer.currentToken; } - - if (ruleStart === null) { - ruleStart = tokenizer.advance(); - ruleEnd = ruleStart; - } else { - ruleEnd = tokenizer.advance(); - } + ruleEnd = tokenizer.advance(); } } - if (tokenizer.currentToken === null) { - // terminated early - return null; - } - // A ruleset never contains or ends with a semi-colon. - if (tokenizer.currentToken.is(Token.type.propertyBoundary)) { + if (!tokenizer.currentToken || + tokenizer.currentToken.is(TokenType.propertyBoundary)) { const nameRange = - tokenizer.getRange(ruleStart!, colon ? colon.previous : ruleEnd); + tokenizer.getRange(ruleStart, colon ? colon.previous : ruleEnd); const declarationName = tokenizer.cssText.slice(nameRange.start, nameRange.end); @@ -296,12 +289,13 @@ class Parser { this.nodeFactory.expression(expressionValue, expressionRange); } - if (tokenizer.currentToken.is(Token.type.semicolon)) { + if (tokenizer.currentToken && + tokenizer.currentToken.is(TokenType.semicolon)) { tokenizer.advance(); } const range = tokenizer.trimRange(tokenizer.getRange( - ruleStart!, + ruleStart, tokenizer.currentToken && tokenizer.currentToken.previous || ruleEnd)); @@ -311,16 +305,17 @@ class Parser { } else if (colon && colon === ruleEnd) { const rulelist = this.parseRulelist(tokenizer); - if (tokenizer.currentToken.is(Token.type.semicolon)) { + if (tokenizer.currentToken && + tokenizer.currentToken.is(TokenType.semicolon)) { tokenizer.advance(); } - const nameRange = tokenizer.getRange(ruleStart!, ruleEnd.previous); + const nameRange = tokenizer.getRange(ruleStart, ruleEnd.previous); const declarationName = tokenizer.cssText.slice(nameRange.start, nameRange.end); const range = tokenizer.trimRange(tokenizer.getRange( - ruleStart!, + ruleStart, tokenizer.currentToken && tokenizer.currentToken.previous || ruleEnd)); @@ -328,16 +323,16 @@ class Parser { declarationName, rulelist, nameRange, range); // Otherwise, this is a ruleset: } else { - const selectorRange = tokenizer.getRange(ruleStart!, ruleEnd); + const selectorRange = tokenizer.getRange(ruleStart, ruleEnd); const selector = tokenizer.cssText.slice(selectorRange.start, selectorRange.end); const rulelist = this.parseRulelist(tokenizer); - const start = ruleStart!.start; + const start = ruleStart.start; let end; if (tokenizer.currentToken) { end = tokenizer.currentToken.previous ? tokenizer.currentToken.previous.end : - ruleStart!.end; + ruleStart.end; } else { // no current token? must have reached the end of input, so go up // until there diff --git a/src/shady-css/token.ts b/src/shady-css/token.ts index ae1c920..797f091 100644 --- a/src/shady-css/token.ts +++ b/src/shady-css/token.ts @@ -12,31 +12,31 @@ /** * An enumeration of Token types. */ -export enum TokenType { +export const enum TokenType { none = 0, - whitespace = (2 ** 0), - string = (2 ** 1), - comment = (2 ** 2), - word = (2 ** 3), - boundary = (2 ** 4), - propertyBoundary = (2 ** 5), + whitespace = (1 << 0), + string = (1 << 1), + comment = (1 << 2), + word = (1 << 3), + boundary = (1 << 4), + propertyBoundary = (1 << 5), // Special cases for boundary: - openParenthesis = (2 ** 6) | TokenType.boundary, - closeParenthesis = (2 ** 7) | TokenType.boundary, - at = (2 ** 8) | TokenType.boundary, - openBrace = (2 ** 9) | TokenType.boundary, + openParenthesis = (1 << 6) | TokenType.boundary, + closeParenthesis = (1 << 7) | TokenType.boundary, + at = (1 << 8) | TokenType.boundary, + openBrace = (1 << 9) | TokenType.boundary, // [};] are property boundaries: - closeBrace = (2 ** 10) | TokenType.propertyBoundary | TokenType.boundary, - semicolon = (2 ** 11) | TokenType.propertyBoundary | TokenType.boundary, + closeBrace = (1 << 10) | TokenType.propertyBoundary | TokenType.boundary, + semicolon = (1 << 11) | TokenType.propertyBoundary | TokenType.boundary, // : is a chimaeric abomination: // foo:bar{} // foo:bar; - colon = (2 ** 12) | TokenType.boundary | TokenType.word, + colon = (1 << 12) | TokenType.boundary | TokenType.word, // TODO: are these two boundaries? I mean, sometimes they are I guess? Or // maybe they shouldn't exist in the boundaryTokenTypes map. - hyphen = (2 ** 13), - underscore = (2 ** 14) + hyphen = (1 << 13), + underscore = (1 << 14) } @@ -44,8 +44,6 @@ export enum TokenType { * Class that describes individual tokens as produced by the Tokenizer. */ class Token { - static type = TokenType; - readonly type: TokenType; readonly start: number; readonly end: number; @@ -84,15 +82,15 @@ class Token { * A mapping of boundary token text to their corresponding types. */ const boundaryTokenTypes: {[boundaryText: string]: TokenType | undefined} = { - '(': Token.type.openParenthesis, - ')': Token.type.closeParenthesis, - ':': Token.type.colon, - '@': Token.type.at, - '{': Token.type.openBrace, - '}': Token.type.closeBrace, - ';': Token.type.semicolon, - '-': Token.type.hyphen, - '_': Token.type.underscore + '(': TokenType.openParenthesis, + ')': TokenType.closeParenthesis, + ':': TokenType.colon, + '@': TokenType.at, + '{': TokenType.openBrace, + '}': TokenType.closeBrace, + ';': TokenType.semicolon, + '-': TokenType.hyphen, + '_': TokenType.underscore }; export {Token, boundaryTokenTypes}; diff --git a/src/shady-css/tokenizer.ts b/src/shady-css/tokenizer.ts index ac9e77e..ffdb0d1 100644 --- a/src/shady-css/tokenizer.ts +++ b/src/shady-css/tokenizer.ts @@ -10,7 +10,7 @@ */ import {matcher, Range} from './common'; -import {boundaryTokenTypes, Token} from './token'; +import {boundaryTokenTypes, Token, TokenType} from './token'; /** * Class that implements tokenization of significant lexical features of the @@ -23,7 +23,7 @@ class Tokenizer { * Tracks the position of the tokenizer in the source string. * Also the default head of the Token linked list. */ - private cursorToken_ = new Token(Token.type.none, 0, 0); + private cursorToken_ = new Token(TokenType.none, 0, 0); /** * Holds a reference to a Token that is "next" in the source string, often @@ -181,7 +181,7 @@ class Tokenizer { } } - return new Token(Token.type.string, start, offset); + return new Token(TokenType.string, start, offset); } /** @@ -200,7 +200,7 @@ class Tokenizer { offset++; } - return new Token(Token.type.word, start, offset); + return new Token(TokenType.word, start, offset); } /** @@ -220,7 +220,7 @@ class Tokenizer { offset = matcher.whitespaceGreedy.lastIndex; } - return new Token(Token.type.whitespace, start, offset); + return new Token(TokenType.whitespace, start, offset); } /** @@ -242,7 +242,7 @@ class Tokenizer { offset = matcher.commentGreedy.lastIndex; } - return new Token(Token.type.comment, start, offset); + return new Token(TokenType.comment, start, offset); } /** @@ -255,7 +255,7 @@ class Tokenizer { tokenizeBoundary(offset: number): Token { // TODO(cdata): Evaluate if this is faster than a switch statement: const type = - boundaryTokenTypes[this.cssText[offset]] || Token.type.boundary; + boundaryTokenTypes[this.cssText[offset]] || TokenType.boundary; return new Token(type, offset, offset + 1); } diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts index db841f1..41e7696 100644 --- a/src/test/fixtures.ts +++ b/src/test/fixtures.ts @@ -68,6 +68,8 @@ div { export const minifiedRuleset = '.foo{bar:baz}div .qux{vim:fet;}'; +export const minifiedRulesetWithExtraSemicolons = '.foo{bar:baz;;}div .qux{vim:fet;}'; + export const psuedoRuleset = '.foo:bar:not(#rif){baz:qux}'; export const dataUriRuleset = '.foo{bar:url(qux;gib)}'; diff --git a/src/test/helpers.ts b/src/test/helpers.ts index 639cf83..53f5686 100644 --- a/src/test/helpers.ts +++ b/src/test/helpers.ts @@ -42,7 +42,7 @@ function linkedTokens(tokens: Token[]) { } return r; - }, new Token(Token.type.none, 0, 0)); + }, new Token(TokenType.none, 0, 0)); return tokens; } diff --git a/src/test/parser-test.ts b/src/test/parser-test.ts index 0ed28d7..ca45cfe 100644 --- a/src/test/parser-test.ts +++ b/src/test/parser-test.ts @@ -147,6 +147,54 @@ describe('Parser', () => { nodeFactory.discarded(';') ])); }); + + it('can parse empty selectors', () => { + expect(parser.parse('{ empty-a } { empty-b } empty-c { empty-d }')) + .to.containSubset(nodeFactory.stylesheet([ + nodeFactory.ruleset( + '', nodeFactory.rulelist([ + nodeFactory.declaration('empty-a', undefined)])), + nodeFactory.ruleset( + '', nodeFactory.rulelist([ + nodeFactory.declaration('empty-b', undefined)])), + nodeFactory.ruleset( + 'empty-c', nodeFactory.rulelist([ + nodeFactory.declaration('empty-d', undefined)])), + ])); + }); + + it('can parse unclosed blocks', () => { + expect(parser.parse('uncl-a { uncl-b: uncl-c uncl-d')) + .to.containSubset(nodeFactory.stylesheet([ + nodeFactory.ruleset( + 'uncl-a', nodeFactory.rulelist([ + nodeFactory.declaration( + 'uncl-b', nodeFactory.expression('uncl-c uncl-d')) + ])) + ])); + }); + + it('can parse unclosed blocks without a colon', () => { + expect(parser.parse('uncol-a { uncol-b')) + .to.containSubset(nodeFactory.stylesheet([ + nodeFactory.ruleset( + 'uncol-a', nodeFactory.rulelist([ + nodeFactory.declaration('uncol-b', undefined) + ])) + ])); + }); + + it('can parse minified rulelists with extra semicolons', () => { + expect(parser.parse(fixtures.minifiedRulesetWithExtraSemicolons)) + .to.containSubset(nodeFactory.stylesheet([ + nodeFactory.ruleset( + '.foo', nodeFactory.rulelist([nodeFactory.declaration( + 'bar', nodeFactory.expression('baz'))])), + nodeFactory.ruleset( + 'div .qux', nodeFactory.rulelist([nodeFactory.declaration( + 'vim', nodeFactory.expression('fet'))])) + ])); + }); }); describe('when extracting ranges', () => { diff --git a/src/test/stringifier-test.ts b/src/test/stringifier-test.ts index e6d7149..9d7834d 100644 --- a/src/test/stringifier-test.ts +++ b/src/test/stringifier-test.ts @@ -107,6 +107,14 @@ describe('Stringifier', () => { expect(cssText).to.be.eql(':root{--qux:vim;--foo:{bar:baz;};}'); }); + it('can stringify empty selectors', () => { + const cssText = + stringifier.stringify(parser.parse( + '{ empty-a } { empty-b } empty-c { empty-d }')); + expect(cssText).to.be + .eql('{empty-a;}{empty-b;}empty-c{empty-d;}'); + }); + describe('with discarded nodes', () => { it('stringifies to a corrected stylesheet', () => { const cssText = diff --git a/src/test/tokenizer-test.ts b/src/test/tokenizer-test.ts index 9baa0ec..d1411ee 100644 --- a/src/test/tokenizer-test.ts +++ b/src/test/tokenizer-test.ts @@ -11,7 +11,7 @@ import {expect} from 'chai'; -import {Token} from '../shady-css/token'; +import {Token, TokenType} from '../shady-css/token'; import {Tokenizer} from '../shady-css/tokenizer'; import * as fixtures from './fixtures'; @@ -21,29 +21,29 @@ describe('Tokenizer', () => { describe('when tokenizing basic structures', () => { it('can identify strings', () => { expect(new Tokenizer('"foo"').flush()).to.be.eql(helpers.linkedTokens([ - new Token(Token.type.string, 0, 5) + new Token(TokenType.string, 0, 5) ])); }); it('can identify comments', () => { expect(new Tokenizer('/*foo*/').flush()).to.be.eql(helpers.linkedTokens([ - new Token(Token.type.comment, 0, 7) + new Token(TokenType.comment, 0, 7) ])); }); it('can identify words', () => { expect(new Tokenizer('font-family').flush()) - .to.be.eql(helpers.linkedTokens([new Token(Token.type.word, 0, 11)])); + .to.be.eql(helpers.linkedTokens([new Token(TokenType.word, 0, 11)])); }); it('can identify boundaries', () => { expect(new Tokenizer('@{};()').flush()).to.be.eql(helpers.linkedTokens([ - new Token(Token.type.at, 0, 1), - new Token(Token.type.openBrace, 1, 2), - new Token(Token.type.closeBrace, 2, 3), - new Token(Token.type.semicolon, 3, 4), - new Token(Token.type.openParenthesis, 4, 5), - new Token(Token.type.closeParenthesis, 5, 6) + new Token(TokenType.at, 0, 1), + new Token(TokenType.openBrace, 1, 2), + new Token(TokenType.closeBrace, 2, 3), + new Token(TokenType.semicolon, 3, 4), + new Token(TokenType.openParenthesis, 4, 5), + new Token(TokenType.closeParenthesis, 5, 6) ])); }); }); @@ -51,99 +51,99 @@ describe('Tokenizer', () => { describe('when tokenizing standard CSS structures', () => { it('can tokenize a basic ruleset', () => { helpers.expectTokenSequence(new Tokenizer(fixtures.basicRuleset), [ - Token.type.whitespace, '\n', Token.type.word, 'body', - Token.type.whitespace, ' ', Token.type.openBrace, '{', - Token.type.whitespace, '\n ', Token.type.word, 'margin', - Token.type.colon, ':', Token.type.whitespace, ' ', - Token.type.word, '0', Token.type.semicolon, ';', - Token.type.whitespace, '\n ', Token.type.word, 'padding', - Token.type.colon, ':', Token.type.whitespace, ' ', - Token.type.word, '0px', Token.type.whitespace, '\n', - Token.type.closeBrace, '}', Token.type.whitespace, '\n' + TokenType.whitespace, '\n', TokenType.word, 'body', + TokenType.whitespace, ' ', TokenType.openBrace, '{', + TokenType.whitespace, '\n ', TokenType.word, 'margin', + TokenType.colon, ':', TokenType.whitespace, ' ', + TokenType.word, '0', TokenType.semicolon, ';', + TokenType.whitespace, '\n ', TokenType.word, 'padding', + TokenType.colon, ':', TokenType.whitespace, ' ', + TokenType.word, '0px', TokenType.whitespace, '\n', + TokenType.closeBrace, '}', TokenType.whitespace, '\n' ]); }); it('can tokenize @rules', () => { helpers.expectTokenSequence(new Tokenizer(fixtures.atRules), [ - Token.type.whitespace, + TokenType.whitespace, '\n', - Token.type.at, + TokenType.at, '@', - Token.type.word, + TokenType.word, 'import', - Token.type.whitespace, + TokenType.whitespace, ' ', - Token.type.word, + TokenType.word, 'url', - Token.type.openParenthesis, + TokenType.openParenthesis, '(', - Token.type.string, + TokenType.string, '\'foo.css\'', - Token.type.closeParenthesis, + TokenType.closeParenthesis, ')', - Token.type.semicolon, + TokenType.semicolon, ';', - Token.type.whitespace, + TokenType.whitespace, '\n\n', - Token.type.at, + TokenType.at, '@', - Token.type.word, + TokenType.word, 'font-face', - Token.type.whitespace, + TokenType.whitespace, ' ', - Token.type.openBrace, + TokenType.openBrace, '{', - Token.type.whitespace, + TokenType.whitespace, '\n ', - Token.type.word, + TokenType.word, 'font-family', - Token.type.colon, + TokenType.colon, ':', - Token.type.whitespace, + TokenType.whitespace, ' ', - Token.type.word, + TokenType.word, 'foo', - Token.type.semicolon, + TokenType.semicolon, ';', - Token.type.whitespace, + TokenType.whitespace, '\n', - Token.type.closeBrace, + TokenType.closeBrace, '}', - Token.type.whitespace, + TokenType.whitespace, '\n\n', - Token.type.at, + TokenType.at, '@', - Token.type.word, + TokenType.word, 'charset', - Token.type.whitespace, + TokenType.whitespace, ' ', - Token.type.string, + TokenType.string, '\'foo\'', - Token.type.semicolon, + TokenType.semicolon, ';', - Token.type.whitespace, + TokenType.whitespace, '\n' ]); }); it('navigates pathological boundary usage', () => { helpers.expectTokenSequence(new Tokenizer(fixtures.extraSemicolons), [ - Token.type.whitespace, '\n', Token.type.colon, ':', - Token.type.word, 'host', Token.type.whitespace, ' ', - Token.type.openBrace, '{', Token.type.whitespace, '\n ', - Token.type.word, 'margin', Token.type.colon, ':', - Token.type.whitespace, ' ', Token.type.word, '0', - Token.type.semicolon, ';', Token.type.semicolon, ';', - Token.type.semicolon, ';', Token.type.whitespace, '\n ', - Token.type.word, 'padding', Token.type.colon, ':', - Token.type.whitespace, ' ', Token.type.word, '0', - Token.type.semicolon, ';', Token.type.semicolon, ';', - Token.type.whitespace, '\n ', Token.type.semicolon, ';', - Token.type.word, 'display', Token.type.colon, ':', - Token.type.whitespace, ' ', Token.type.word, 'block', - Token.type.semicolon, ';', Token.type.whitespace, '\n', - Token.type.closeBrace, '}', Token.type.semicolon, ';', - Token.type.whitespace, '\n' + TokenType.whitespace, '\n', TokenType.colon, ':', + TokenType.word, 'host', TokenType.whitespace, ' ', + TokenType.openBrace, '{', TokenType.whitespace, '\n ', + TokenType.word, 'margin', TokenType.colon, ':', + TokenType.whitespace, ' ', TokenType.word, '0', + TokenType.semicolon, ';', TokenType.semicolon, ';', + TokenType.semicolon, ';', TokenType.whitespace, '\n ', + TokenType.word, 'padding', TokenType.colon, ':', + TokenType.whitespace, ' ', TokenType.word, '0', + TokenType.semicolon, ';', TokenType.semicolon, ';', + TokenType.whitespace, '\n ', TokenType.semicolon, ';', + TokenType.word, 'display', TokenType.colon, ':', + TokenType.whitespace, ' ', TokenType.word, 'block', + TokenType.semicolon, ';', TokenType.whitespace, '\n', + TokenType.closeBrace, '}', TokenType.semicolon, ';', + TokenType.whitespace, '\n' ]); }); }); @@ -152,7 +152,7 @@ describe('Tokenizer', () => { it('can slice the string using tokens', () => { const tokenizer = new Tokenizer('foo bar'); const substring = tokenizer.slice( - new Token(Token.type.word, 2, 3), new Token(Token.type.word, 5, 6)); + new Token(TokenType.word, 2, 3), new Token(TokenType.word, 5, 6)); expect(substring).to.be.eql('o ba'); }); });