diff --git a/src/server/common/cssParser.ts b/src/server/common/cssParser.ts new file mode 100644 index 0000000000..01ae864397 --- /dev/null +++ b/src/server/common/cssParser.ts @@ -0,0 +1,238 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as css from './cssTokenizer'; + +// TODO: Consider giving more information, e.g. whether the argument was a quoted string or not. +type ParsedSelectorLiteral = string; +type ClauseCombinator = '' | '>' | '+' | '~'; +export type ParsedSelectorClause = ParsedSelectorLiteral | { css?: string, funcs: { name: string, args: ParsedSelectorList }[] }; +export type ParsedSelector = { clauses: { clause: ParsedSelectorClause, combinator: ClauseCombinator }[] }; +export type ParsedSelectorList = (ParsedSelectorLiteral | ParsedSelector)[]; + +export function parseCSS(selector: string): ParsedSelectorList { + let tokens: css.CSSTokenInterface[]; + try { + tokens = css.tokenize(selector); + if (!(tokens[tokens.length - 1] instanceof css.EOFToken)) + tokens.push(new css.EOFToken()); + } catch (e) { + const newMessage = e.message + ` while parsing selector "${selector}"`; + const index = (e.stack || '').indexOf(e.message); + if (index !== -1) + e.stack = e.stack.substring(0, index) + newMessage + e.stack.substring(index + e.message.length); + e.message = newMessage; + throw e; + } + const unsupportedToken = tokens.find(token => { + return (token instanceof css.AtKeywordToken) || + (token instanceof css.BadStringToken) || + (token instanceof css.BadURLToken) || + (token instanceof css.ColumnToken) || + (token instanceof css.CDOToken) || + (token instanceof css.CDCToken) || + (token instanceof css.SemicolonToken) || + // TODO: Consider using these for something, e.g. to escape complex strings. + // For example :xpath{ (//div/bar[@attr="foo"])[2]/baz } + // Or this way :xpath( {complex-xpath-goes-here("hello")} ) + (token instanceof css.OpenCurlyToken) || + (token instanceof css.CloseCurlyToken) || + // TODO: Consider treating these as strings? + (token instanceof css.URLToken) || + (token instanceof css.PercentageToken); + }); + if (unsupportedToken) + throw new Error(`Unsupported token "${unsupportedToken.toSource()}" while parsing selector "${selector}"`); + + let pos = 0; + + function unexpected() { + return new Error(`Unexpected token "${tokens[pos].toSource()}" while parsing selector "${selector}"`); + } + + function skipWhitespace() { + while (tokens[pos] instanceof css.WhitespaceToken) + pos++; + } + + function isIdent(p = pos) { + return tokens[p] instanceof css.IdentToken; + } + + function isString(p = pos) { + return tokens[p] instanceof css.StringToken; + } + + function isNumber(p = pos) { + return tokens[p] instanceof css.NumberToken; + } + + function isComma(p = pos) { + return tokens[p] instanceof css.CommaToken; + } + + function isCloseParen(p = pos) { + return tokens[p] instanceof css.CloseParenToken; + } + + function isStar(p = pos) { + return (tokens[p] instanceof css.DelimToken) && tokens[p].value === '*'; + } + + function isEOF(p = pos) { + return tokens[p] instanceof css.EOFToken; + } + + function isClauseCombinator(p = pos) { + return (tokens[p] instanceof css.DelimToken) && (['>', '+', '~'].includes(tokens[p].value)); + } + + function isSelectorClauseEnd(p = pos) { + return isComma(p) || isCloseParen(p) || isEOF(p) || isClauseCombinator(p) || (tokens[p] instanceof css.WhitespaceToken); + } + + function consumeSelectorList(): ParsedSelectorList { + const result = [consumeSelector()]; + while (true) { + skipWhitespace(); + if (!isComma()) + break; + pos++; + result.push(consumeSelector()); + } + return result; + } + + function consumeSelector(): ParsedSelector | ParsedSelectorLiteral { + skipWhitespace(); + const result = { clauses: [{ clause: consumeSelectorClause(), combinator: '' as ClauseCombinator }] }; + while (true) { + skipWhitespace(); + if (isClauseCombinator()) { + result.clauses[result.clauses.length - 1].combinator = tokens[pos++].value as ClauseCombinator; + skipWhitespace(); + } else if (isSelectorClauseEnd()) { + break; + } + result.clauses.push({ combinator: '', clause: consumeSelectorClause() }); + } + if (result.clauses.length === 1 && typeof result.clauses[0].clause === 'string') + return result.clauses[0].clause; + return result; + } + + function consumeSelectorClause(): ParsedSelectorClause { + // TODO: Consider symbols like `=`, `|=`, `~=`, `*=`, `/` and convert them to strings. + if ((isNumber() || isString() || isStar() || isIdent()) && isSelectorClauseEnd(pos + 1)) + return isString() ? tokens[pos++].value : tokens[pos++].toSource(); + + let rawCSSString = ''; + const funcs: { name: string, args: ParsedSelectorList }[] = []; + + while (!isSelectorClauseEnd()) { + if (isIdent() || isStar()) { + rawCSSString += tokens[pos++].toSource(); + } else if (tokens[pos] instanceof css.HashToken) { + rawCSSString += tokens[pos++].toSource(); + } else if ((tokens[pos] instanceof css.DelimToken) && tokens[pos].value === '.') { + pos++; + if (isIdent()) + rawCSSString += '.' + tokens[pos++].toSource(); + else + throw unexpected(); + } else if (tokens[pos] instanceof css.ColonToken) { + pos++; + if (isIdent()) { + if (cssFilters.has(tokens[pos].value)) + rawCSSString += ':' + tokens[pos++].toSource(); + else + funcs.push({ name: tokens[pos++].value, args: [] }); + } else if (tokens[pos] instanceof css.FunctionToken) { + const name = tokens[pos++].value; + if (cssFunctions.has(name)) + rawCSSString += `:${name}(${consumeCSSFunctionArgs()})`; + else + funcs.push({ name, args: consumeSelectorList() }); + skipWhitespace(); + if (!isCloseParen()) + throw unexpected(); + pos++; + } else { + throw unexpected(); + } + } else if (tokens[pos] instanceof css.OpenSquareToken) { + rawCSSString += '['; + pos++; + while (!(tokens[pos] instanceof css.CloseSquareToken) && !isEOF()) + rawCSSString += tokens[pos++].toSource(); + if (!(tokens[pos] instanceof css.CloseSquareToken)) + throw unexpected(); + rawCSSString += ']'; + pos++; + } else { + throw unexpected(); + } + } + if (!rawCSSString && !funcs.length) + throw unexpected(); + return { css: rawCSSString || undefined, funcs }; + } + + function consumeCSSFunctionArgs(): string { + let s = ''; + while (!isCloseParen() && !isEOF()) + s += tokens[pos++].toSource(); + return s; + } + + const result = consumeSelectorList(); + if (!isEOF()) + throw new Error(`Error while parsing selector "${selector}"`); + return result; +} + +export function serializeSelector(selectorList: ParsedSelectorList) { + return selectorList.map(selector => { + if (typeof selector === 'string') + return selector; + return selector.clauses.map(({ clause, combinator }) => { + let s = ''; + if (typeof clause === 'string') { + s = clause; + } else { + if (clause.css) + s = clause.css; + s = s + clause.funcs.map(func => `:${func.name}(${serializeSelector(func.args)})`).join(''); + } + if (combinator) + s += ' ' + combinator; + return s; + }).join(' '); + }).join(', '); +} + +const cssFilters = new Set([ + 'active', 'any-link', 'checked', 'blank', 'default', 'defined', + 'disabled', 'empty', 'enabled', 'first', 'first-child', 'first-of-type', + 'fullscreen', 'focus', 'focus-visible', 'focus-within', 'hover', + 'indeterminate', 'in-range', 'invalid', 'last-child', 'last-of-type', + 'link', 'only-child', 'only-of-type', 'optional', 'out-of-range', 'placeholder-shown', + 'read-only', 'read-write', 'required', 'root', 'target', 'valid', 'visited', +]); + +const cssFunctions = new Set([ + 'dir', 'lang', 'nth-child', 'nth-last-child', 'nth-last-of-type', 'nth-of-type', +]); diff --git a/src/server/common/cssTokenizer.d.ts b/src/server/common/cssTokenizer.d.ts new file mode 100644 index 0000000000..1678e3a24b --- /dev/null +++ b/src/server/common/cssTokenizer.d.ts @@ -0,0 +1,54 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export class CSSTokenInterface { + toSource(): string; + value: string; +} + +export function tokenize(selector: string): CSSTokenInterface[]; + +export class IdentToken extends CSSTokenInterface {} +export class FunctionToken extends CSSTokenInterface {} +export class AtKeywordToken extends CSSTokenInterface {} +export class HashToken extends CSSTokenInterface {} +export class StringToken extends CSSTokenInterface {} +export class BadStringToken extends CSSTokenInterface {} +export class URLToken extends CSSTokenInterface {} +export class BadURLToken extends CSSTokenInterface {} +export class DelimToken extends CSSTokenInterface {} +export class NumberToken extends CSSTokenInterface {} +export class PercentageToken extends CSSTokenInterface {} +export class DimensionToken extends CSSTokenInterface {} +export class IncludeMatchToken extends CSSTokenInterface {} +export class DashMatchToken extends CSSTokenInterface {} +export class PrefixMatchToken extends CSSTokenInterface {} +export class SuffixMatchToken extends CSSTokenInterface {} +export class SubstringMatchToken extends CSSTokenInterface {} +export class ColumnToken extends CSSTokenInterface {} +export class WhitespaceToken extends CSSTokenInterface {} +export class CDOToken extends CSSTokenInterface {} +export class CDCToken extends CSSTokenInterface {} +export class ColonToken extends CSSTokenInterface {} +export class SemicolonToken extends CSSTokenInterface {} +export class CommaToken extends CSSTokenInterface {} +export class OpenParenToken extends CSSTokenInterface {} +export class CloseParenToken extends CSSTokenInterface {} +export class OpenSquareToken extends CSSTokenInterface {} +export class CloseSquareToken extends CSSTokenInterface {} +export class OpenCurlyToken extends CSSTokenInterface {} +export class CloseCurlyToken extends CSSTokenInterface {} +export class EOFToken extends CSSTokenInterface {} diff --git a/src/server/common/cssTokenizer.js b/src/server/common/cssTokenizer.js new file mode 100644 index 0000000000..e77ddf1df1 --- /dev/null +++ b/src/server/common/cssTokenizer.js @@ -0,0 +1,950 @@ +/* + * Original at https://github.com/tabatkins/parse-css + * licensed under http://creativecommons.org/publicdomain/zero/1.0/ + * + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Changes from https://github.com/tabatkins/parse-css +// - Tabs are replaced with two spaces. +// - Everything not related to tokenizing - below the first exports block - is removed. + +// @ts-nocheck + +(function (root, factory) { + // Universal Module Definition (UMD) to support AMD, CommonJS/Node.js, + // Rhino, and plain browser loading. + if (typeof define === 'function' && define.amd) { + define(['exports'], factory); + } else if (typeof exports !== 'undefined') { + factory(exports); + } else { + factory(root); + } +}(this, function (exports) { + +var between = function (num, first, last) { return num >= first && num <= last; } +function digit(code) { return between(code, 0x30,0x39); } +function hexdigit(code) { return digit(code) || between(code, 0x41,0x46) || between(code, 0x61,0x66); } +function uppercaseletter(code) { return between(code, 0x41,0x5a); } +function lowercaseletter(code) { return between(code, 0x61,0x7a); } +function letter(code) { return uppercaseletter(code) || lowercaseletter(code); } +function nonascii(code) { return code >= 0x80; } +function namestartchar(code) { return letter(code) || nonascii(code) || code == 0x5f; } +function namechar(code) { return namestartchar(code) || digit(code) || code == 0x2d; } +function nonprintable(code) { return between(code, 0,8) || code == 0xb || between(code, 0xe,0x1f) || code == 0x7f; } +function newline(code) { return code == 0xa; } +function whitespace(code) { return newline(code) || code == 9 || code == 0x20; } +function badescape(code) { return newline(code) || isNaN(code); } + +var maximumallowedcodepoint = 0x10ffff; + +var InvalidCharacterError = function(message) { + this.message = message; +}; +InvalidCharacterError.prototype = new Error; +InvalidCharacterError.prototype.name = 'InvalidCharacterError'; + +function preprocess(str) { + // Turn a string into an array of code points, + // following the preprocessing cleanup rules. + var codepoints = []; + for(var i = 0; i < str.length; i++) { + var code = str.charCodeAt(i); + if(code == 0xd && str.charCodeAt(i+1) == 0xa) { + code = 0xa; i++; + } + if(code == 0xd || code == 0xc) code = 0xa; + if(code == 0x0) code = 0xfffd; + if(between(code, 0xd800, 0xdbff) && between(str.charCodeAt(i+1), 0xdc00, 0xdfff)) { + // Decode a surrogate pair into an astral codepoint. + var lead = code - 0xd800; + var trail = str.charCodeAt(i+1) - 0xdc00; + code = Math.pow(2, 16) + lead * Math.pow(2, 10) + trail; + i++; + } + codepoints.push(code); + } + return codepoints; +} + +function stringFromCode(code) { + if(code <= 0xffff) return String.fromCharCode(code); + // Otherwise, encode astral char as surrogate pair. + code -= Math.pow(2, 16); + var lead = Math.floor(code/Math.pow(2, 10)) + 0xd800; + var trail = code % Math.pow(2, 10) + 0xdc00; + return String.fromCharCode(lead) + String.fromCharCode(trail); +} + +function tokenize(str) { + str = preprocess(str); + var i = -1; + var tokens = []; + var code; + + // Line number information. + var line = 0; + var column = 0; + // The only use of lastLineLength is in reconsume(). + var lastLineLength = 0; + var incrLineno = function() { + line += 1; + lastLineLength = column; + column = 0; + }; + var locStart = {line:line, column:column}; + + var codepoint = function(i) { + if(i >= str.length) { + return -1; + } + return str[i]; + } + var next = function(num) { + if(num === undefined) + num = 1; + if(num > 3) + throw "Spec Error: no more than three codepoints of lookahead."; + return codepoint(i+num); + }; + var consume = function(num) { + if(num === undefined) + num = 1; + i += num; + code = codepoint(i); + if(newline(code)) incrLineno(); + else column += num; + //console.log('Consume '+i+' '+String.fromCharCode(code) + ' 0x' + code.toString(16)); + return true; + }; + var reconsume = function() { + i -= 1; + if (newline(code)) { + line -= 1; + column = lastLineLength; + } else { + column -= 1; + } + locStart.line = line; + locStart.column = column; + return true; + }; + var eof = function(codepoint) { + if(codepoint === undefined) codepoint = code; + return codepoint == -1; + }; + var donothing = function() {}; + var parseerror = function() { console.log("Parse error at index " + i + ", processing codepoint 0x" + code.toString(16) + ".");return true; }; + + var consumeAToken = function() { + consumeComments(); + consume(); + if(whitespace(code)) { + while(whitespace(next())) consume(); + return new WhitespaceToken; + } + else if(code == 0x22) return consumeAStringToken(); + else if(code == 0x23) { + if(namechar(next()) || areAValidEscape(next(1), next(2))) { + var token = new HashToken(); + if(wouldStartAnIdentifier(next(1), next(2), next(3))) token.type = "id"; + token.value = consumeAName(); + return token; + } else { + return new DelimToken(code); + } + } + else if(code == 0x24) { + if(next() == 0x3d) { + consume(); + return new SuffixMatchToken(); + } else { + return new DelimToken(code); + } + } + else if(code == 0x27) return consumeAStringToken(); + else if(code == 0x28) return new OpenParenToken(); + else if(code == 0x29) return new CloseParenToken(); + else if(code == 0x2a) { + if(next() == 0x3d) { + consume(); + return new SubstringMatchToken(); + } else { + return new DelimToken(code); + } + } + else if(code == 0x2b) { + if(startsWithANumber()) { + reconsume(); + return consumeANumericToken(); + } else { + return new DelimToken(code); + } + } + else if(code == 0x2c) return new CommaToken(); + else if(code == 0x2d) { + if(startsWithANumber()) { + reconsume(); + return consumeANumericToken(); + } else if(next(1) == 0x2d && next(2) == 0x3e) { + consume(2); + return new CDCToken(); + } else if(startsWithAnIdentifier()) { + reconsume(); + return consumeAnIdentlikeToken(); + } else { + return new DelimToken(code); + } + } + else if(code == 0x2e) { + if(startsWithANumber()) { + reconsume(); + return consumeANumericToken(); + } else { + return new DelimToken(code); + } + } + else if(code == 0x3a) return new ColonToken; + else if(code == 0x3b) return new SemicolonToken; + else if(code == 0x3c) { + if(next(1) == 0x21 && next(2) == 0x2d && next(3) == 0x2d) { + consume(3); + return new CDOToken(); + } else { + return new DelimToken(code); + } + } + else if(code == 0x40) { + if(wouldStartAnIdentifier(next(1), next(2), next(3))) { + return new AtKeywordToken(consumeAName()); + } else { + return new DelimToken(code); + } + } + else if(code == 0x5b) return new OpenSquareToken(); + else if(code == 0x5c) { + if(startsWithAValidEscape()) { + reconsume(); + return consumeAnIdentlikeToken(); + } else { + parseerror(); + return new DelimToken(code); + } + } + else if(code == 0x5d) return new CloseSquareToken(); + else if(code == 0x5e) { + if(next() == 0x3d) { + consume(); + return new PrefixMatchToken(); + } else { + return new DelimToken(code); + } + } + else if(code == 0x7b) return new OpenCurlyToken(); + else if(code == 0x7c) { + if(next() == 0x3d) { + consume(); + return new DashMatchToken(); + } else if(next() == 0x7c) { + consume(); + return new ColumnToken(); + } else { + return new DelimToken(code); + } + } + else if(code == 0x7d) return new CloseCurlyToken(); + else if(code == 0x7e) { + if(next() == 0x3d) { + consume(); + return new IncludeMatchToken(); + } else { + return new DelimToken(code); + } + } + else if(digit(code)) { + reconsume(); + return consumeANumericToken(); + } + else if(namestartchar(code)) { + reconsume(); + return consumeAnIdentlikeToken(); + } + else if(eof()) return new EOFToken(); + else return new DelimToken(code); + }; + + var consumeComments = function() { + while(next(1) == 0x2f && next(2) == 0x2a) { + consume(2); + while(true) { + consume(); + if(code == 0x2a && next() == 0x2f) { + consume(); + break; + } else if(eof()) { + parseerror(); + return; + } + } + } + }; + + var consumeANumericToken = function() { + var num = consumeANumber(); + if(wouldStartAnIdentifier(next(1), next(2), next(3))) { + var token = new DimensionToken(); + token.value = num.value; + token.repr = num.repr; + token.type = num.type; + token.unit = consumeAName(); + return token; + } else if(next() == 0x25) { + consume(); + var token = new PercentageToken(); + token.value = num.value; + token.repr = num.repr; + return token; + } else { + var token = new NumberToken(); + token.value = num.value; + token.repr = num.repr; + token.type = num.type; + return token; + } + }; + + var consumeAnIdentlikeToken = function() { + var str = consumeAName(); + if(str.toLowerCase() == "url" && next() == 0x28) { + consume(); + while(whitespace(next(1)) && whitespace(next(2))) consume(); + if(next() == 0x22 || next() == 0x27) { + return new FunctionToken(str); + } else if(whitespace(next()) && (next(2) == 0x22 || next(2) == 0x27)) { + return new FunctionToken(str); + } else { + return consumeAURLToken(); + } + } else if(next() == 0x28) { + consume(); + return new FunctionToken(str); + } else { + return new IdentToken(str); + } + }; + + var consumeAStringToken = function(endingCodePoint) { + if(endingCodePoint === undefined) endingCodePoint = code; + var string = ""; + while(consume()) { + if(code == endingCodePoint || eof()) { + return new StringToken(string); + } else if(newline(code)) { + parseerror(); + reconsume(); + return new BadStringToken(); + } else if(code == 0x5c) { + if(eof(next())) { + donothing(); + } else if(newline(next())) { + consume(); + } else { + string += stringFromCode(consumeEscape()) + } + } else { + string += stringFromCode(code); + } + } + }; + + var consumeAURLToken = function() { + var token = new URLToken(""); + while(whitespace(next())) consume(); + if(eof(next())) return token; + while(consume()) { + if(code == 0x29 || eof()) { + return token; + } else if(whitespace(code)) { + while(whitespace(next())) consume(); + if(next() == 0x29 || eof(next())) { + consume(); + return token; + } else { + consumeTheRemnantsOfABadURL(); + return new BadURLToken(); + } + } else if(code == 0x22 || code == 0x27 || code == 0x28 || nonprintable(code)) { + parseerror(); + consumeTheRemnantsOfABadURL(); + return new BadURLToken(); + } else if(code == 0x5c) { + if(startsWithAValidEscape()) { + token.value += stringFromCode(consumeEscape()); + } else { + parseerror(); + consumeTheRemnantsOfABadURL(); + return new BadURLToken(); + } + } else { + token.value += stringFromCode(code); + } + } + }; + + var consumeEscape = function() { + // Assume the the current character is the \ + // and the next code point is not a newline. + consume(); + if(hexdigit(code)) { + // Consume 1-6 hex digits + var digits = [code]; + for(var total = 0; total < 5; total++) { + if(hexdigit(next())) { + consume(); + digits.push(code); + } else { + break; + } + } + if(whitespace(next())) consume(); + var value = parseInt(digits.map(function(x){return String.fromCharCode(x);}).join(''), 16); + if( value > maximumallowedcodepoint ) value = 0xfffd; + return value; + } else if(eof()) { + return 0xfffd; + } else { + return code; + } + }; + + var areAValidEscape = function(c1, c2) { + if(c1 != 0x5c) return false; + if(newline(c2)) return false; + return true; + }; + var startsWithAValidEscape = function() { + return areAValidEscape(code, next()); + }; + + var wouldStartAnIdentifier = function(c1, c2, c3) { + if(c1 == 0x2d) { + return namestartchar(c2) || c2 == 0x2d || areAValidEscape(c2, c3); + } else if(namestartchar(c1)) { + return true; + } else if(c1 == 0x5c) { + return areAValidEscape(c1, c2); + } else { + return false; + } + }; + var startsWithAnIdentifier = function() { + return wouldStartAnIdentifier(code, next(1), next(2)); + }; + + var wouldStartANumber = function(c1, c2, c3) { + if(c1 == 0x2b || c1 == 0x2d) { + if(digit(c2)) return true; + if(c2 == 0x2e && digit(c3)) return true; + return false; + } else if(c1 == 0x2e) { + if(digit(c2)) return true; + return false; + } else if(digit(c1)) { + return true; + } else { + return false; + } + }; + var startsWithANumber = function() { + return wouldStartANumber(code, next(1), next(2)); + }; + + var consumeAName = function() { + var result = ""; + while(consume()) { + if(namechar(code)) { + result += stringFromCode(code); + } else if(startsWithAValidEscape()) { + result += stringFromCode(consumeEscape()); + } else { + reconsume(); + return result; + } + } + }; + + var consumeANumber = function() { + var repr = []; + var type = "integer"; + if(next() == 0x2b || next() == 0x2d) { + consume(); + repr += stringFromCode(code); + } + while(digit(next())) { + consume(); + repr += stringFromCode(code); + } + if(next(1) == 0x2e && digit(next(2))) { + consume(); + repr += stringFromCode(code); + consume(); + repr += stringFromCode(code); + type = "number"; + while(digit(next())) { + consume(); + repr += stringFromCode(code); + } + } + var c1 = next(1), c2 = next(2), c3 = next(3); + if((c1 == 0x45 || c1 == 0x65) && digit(c2)) { + consume(); + repr += stringFromCode(code); + consume(); + repr += stringFromCode(code); + type = "number"; + while(digit(next())) { + consume(); + repr += stringFromCode(code); + } + } else if((c1 == 0x45 || c1 == 0x65) && (c2 == 0x2b || c2 == 0x2d) && digit(c3)) { + consume(); + repr += stringFromCode(code); + consume(); + repr += stringFromCode(code); + consume(); + repr += stringFromCode(code); + type = "number"; + while(digit(next())) { + consume(); + repr += stringFromCode(code); + } + } + var value = convertAStringToANumber(repr); + return {type:type, value:value, repr:repr}; + }; + + var convertAStringToANumber = function(string) { + // CSS's number rules are identical to JS, afaik. + return +string; + }; + + var consumeTheRemnantsOfABadURL = function() { + while(consume()) { + if(code == 0x29 || eof()) { + return; + } else if(startsWithAValidEscape()) { + consumeEscape(); + donothing(); + } else { + donothing(); + } + } + }; + + + + var iterationCount = 0; + while(!eof(next())) { + tokens.push(consumeAToken()); + iterationCount++; + if(iterationCount > str.length*2) return "I'm infinite-looping!"; + } + return tokens; +} + +function CSSParserToken() { throw "Abstract Base Class"; } +CSSParserToken.prototype.toJSON = function() { + return {token: this.tokenType}; +} +CSSParserToken.prototype.toString = function() { return this.tokenType; } +CSSParserToken.prototype.toSource = function() { return ''+this; } + +function BadStringToken() { return this; } +BadStringToken.prototype = Object.create(CSSParserToken.prototype); +BadStringToken.prototype.tokenType = "BADSTRING"; + +function BadURLToken() { return this; } +BadURLToken.prototype = Object.create(CSSParserToken.prototype); +BadURLToken.prototype.tokenType = "BADURL"; + +function WhitespaceToken() { return this; } +WhitespaceToken.prototype = Object.create(CSSParserToken.prototype); +WhitespaceToken.prototype.tokenType = "WHITESPACE"; +WhitespaceToken.prototype.toString = function() { return "WS"; } +WhitespaceToken.prototype.toSource = function() { return " "; } + +function CDOToken() { return this; } +CDOToken.prototype = Object.create(CSSParserToken.prototype); +CDOToken.prototype.tokenType = "CDO"; +CDOToken.prototype.toSource = function() { return ""; } + +function ColonToken() { return this; } +ColonToken.prototype = Object.create(CSSParserToken.prototype); +ColonToken.prototype.tokenType = ":"; + +function SemicolonToken() { return this; } +SemicolonToken.prototype = Object.create(CSSParserToken.prototype); +SemicolonToken.prototype.tokenType = ";"; + +function CommaToken() { return this; } +CommaToken.prototype = Object.create(CSSParserToken.prototype); +CommaToken.prototype.tokenType = ","; + +function GroupingToken() { throw "Abstract Base Class"; } +GroupingToken.prototype = Object.create(CSSParserToken.prototype); + +function OpenCurlyToken() { this.value = "{"; this.mirror = "}"; return this; } +OpenCurlyToken.prototype = Object.create(GroupingToken.prototype); +OpenCurlyToken.prototype.tokenType = "{"; + +function CloseCurlyToken() { this.value = "}"; this.mirror = "{"; return this; } +CloseCurlyToken.prototype = Object.create(GroupingToken.prototype); +CloseCurlyToken.prototype.tokenType = "}"; + +function OpenSquareToken() { this.value = "["; this.mirror = "]"; return this; } +OpenSquareToken.prototype = Object.create(GroupingToken.prototype); +OpenSquareToken.prototype.tokenType = "["; + +function CloseSquareToken() { this.value = "]"; this.mirror = "["; return this; } +CloseSquareToken.prototype = Object.create(GroupingToken.prototype); +CloseSquareToken.prototype.tokenType = "]"; + +function OpenParenToken() { this.value = "("; this.mirror = ")"; return this; } +OpenParenToken.prototype = Object.create(GroupingToken.prototype); +OpenParenToken.prototype.tokenType = "("; + +function CloseParenToken() { this.value = ")"; this.mirror = "("; return this; } +CloseParenToken.prototype = Object.create(GroupingToken.prototype); +CloseParenToken.prototype.tokenType = ")"; + +function IncludeMatchToken() { return this; } +IncludeMatchToken.prototype = Object.create(CSSParserToken.prototype); +IncludeMatchToken.prototype.tokenType = "~="; + +function DashMatchToken() { return this; } +DashMatchToken.prototype = Object.create(CSSParserToken.prototype); +DashMatchToken.prototype.tokenType = "|="; + +function PrefixMatchToken() { return this; } +PrefixMatchToken.prototype = Object.create(CSSParserToken.prototype); +PrefixMatchToken.prototype.tokenType = "^="; + +function SuffixMatchToken() { return this; } +SuffixMatchToken.prototype = Object.create(CSSParserToken.prototype); +SuffixMatchToken.prototype.tokenType = "$="; + +function SubstringMatchToken() { return this; } +SubstringMatchToken.prototype = Object.create(CSSParserToken.prototype); +SubstringMatchToken.prototype.tokenType = "*="; + +function ColumnToken() { return this; } +ColumnToken.prototype = Object.create(CSSParserToken.prototype); +ColumnToken.prototype.tokenType = "||"; + +function EOFToken() { return this; } +EOFToken.prototype = Object.create(CSSParserToken.prototype); +EOFToken.prototype.tokenType = "EOF"; +EOFToken.prototype.toSource = function() { return ""; } + +function DelimToken(code) { + this.value = stringFromCode(code); + return this; +} +DelimToken.prototype = Object.create(CSSParserToken.prototype); +DelimToken.prototype.tokenType = "DELIM"; +DelimToken.prototype.toString = function() { return "DELIM("+this.value+")"; } +DelimToken.prototype.toJSON = function() { + var json = this.constructor.prototype.constructor.prototype.toJSON.call(this); + json.value = this.value; + return json; +} +DelimToken.prototype.toSource = function() { + if(this.value == "\\") + return "\\\n"; + else + return this.value; +} + +function StringValuedToken() { throw "Abstract Base Class"; } +StringValuedToken.prototype = Object.create(CSSParserToken.prototype); +StringValuedToken.prototype.ASCIIMatch = function(str) { + return this.value.toLowerCase() == str.toLowerCase(); +} +StringValuedToken.prototype.toJSON = function() { + var json = this.constructor.prototype.constructor.prototype.toJSON.call(this); + json.value = this.value; + return json; +} + +function IdentToken(val) { + this.value = val; +} +IdentToken.prototype = Object.create(StringValuedToken.prototype); +IdentToken.prototype.tokenType = "IDENT"; +IdentToken.prototype.toString = function() { return "IDENT("+this.value+")"; } +IdentToken.prototype.toSource = function() { + return escapeIdent(this.value); +} + +function FunctionToken(val) { + this.value = val; + this.mirror = ")"; +} +FunctionToken.prototype = Object.create(StringValuedToken.prototype); +FunctionToken.prototype.tokenType = "FUNCTION"; +FunctionToken.prototype.toString = function() { return "FUNCTION("+this.value+")"; } +FunctionToken.prototype.toSource = function() { + return escapeIdent(this.value) + "("; +} + +function AtKeywordToken(val) { + this.value = val; +} +AtKeywordToken.prototype = Object.create(StringValuedToken.prototype); +AtKeywordToken.prototype.tokenType = "AT-KEYWORD"; +AtKeywordToken.prototype.toString = function() { return "AT("+this.value+")"; } +AtKeywordToken.prototype.toSource = function() { + return "@" + escapeIdent(this.value); +} + +function HashToken(val) { + this.value = val; + this.type = "unrestricted"; +} +HashToken.prototype = Object.create(StringValuedToken.prototype); +HashToken.prototype.tokenType = "HASH"; +HashToken.prototype.toString = function() { return "HASH("+this.value+")"; } +HashToken.prototype.toJSON = function() { + var json = this.constructor.prototype.constructor.prototype.toJSON.call(this); + json.value = this.value; + json.type = this.type; + return json; +} +HashToken.prototype.toSource = function() { + if(this.type == "id") { + return "#" + escapeIdent(this.value); + } else { + return "#" + escapeHash(this.value); + } +} + +function StringToken(val) { + this.value = val; +} +StringToken.prototype = Object.create(StringValuedToken.prototype); +StringToken.prototype.tokenType = "STRING"; +StringToken.prototype.toString = function() { + return '"' + escapeString(this.value) + '"'; +} + +function URLToken(val) { + this.value = val; +} +URLToken.prototype = Object.create(StringValuedToken.prototype); +URLToken.prototype.tokenType = "URL"; +URLToken.prototype.toString = function() { return "URL("+this.value+")"; } +URLToken.prototype.toSource = function() { + return 'url("' + escapeString(this.value) + '")'; +} + +function NumberToken() { + this.value = null; + this.type = "integer"; + this.repr = ""; +} +NumberToken.prototype = Object.create(CSSParserToken.prototype); +NumberToken.prototype.tokenType = "NUMBER"; +NumberToken.prototype.toString = function() { + if(this.type == "integer") + return "INT("+this.value+")"; + return "NUMBER("+this.value+")"; +} +NumberToken.prototype.toJSON = function() { + var json = this.constructor.prototype.constructor.prototype.toJSON.call(this); + json.value = this.value; + json.type = this.type; + json.repr = this.repr; + return json; +} +NumberToken.prototype.toSource = function() { return this.repr; }; + +function PercentageToken() { + this.value = null; + this.repr = ""; +} +PercentageToken.prototype = Object.create(CSSParserToken.prototype); +PercentageToken.prototype.tokenType = "PERCENTAGE"; +PercentageToken.prototype.toString = function() { return "PERCENTAGE("+this.value+")"; } +PercentageToken.prototype.toJSON = function() { + var json = this.constructor.prototype.constructor.prototype.toJSON.call(this); + json.value = this.value; + json.repr = this.repr; + return json; +} +PercentageToken.prototype.toSource = function() { return this.repr + "%"; } + +function DimensionToken() { + this.value = null; + this.type = "integer"; + this.repr = ""; + this.unit = ""; +} +DimensionToken.prototype = Object.create(CSSParserToken.prototype); +DimensionToken.prototype.tokenType = "DIMENSION"; +DimensionToken.prototype.toString = function() { return "DIM("+this.value+","+this.unit+")"; } +DimensionToken.prototype.toJSON = function() { + var json = this.constructor.prototype.constructor.prototype.toJSON.call(this); + json.value = this.value; + json.type = this.type; + json.repr = this.repr; + json.unit = this.unit; + return json; +} +DimensionToken.prototype.toSource = function() { + var source = this.repr; + var unit = escapeIdent(this.unit); + if(unit[0].toLowerCase() == "e" && (unit[1] == "-" || between(unit.charCodeAt(1), 0x30, 0x39))) { + // Unit is ambiguous with scinot + // Remove the leading "e", replace with escape. + unit = "\\65 " + unit.slice(1, unit.length); + } + return source+unit; +} + +function escapeIdent(string) { + string = ''+string; + var result = ''; + var firstcode = string.charCodeAt(0); + for(var i = 0; i < string.length; i++) { + var code = string.charCodeAt(i); + if(code == 0x0) { + throw new InvalidCharacterError('Invalid character: the input contains U+0000.'); + } + + if( + between(code, 0x1, 0x1f) || code == 0x7f || + (i == 0 && between(code, 0x30, 0x39)) || + (i == 1 && between(code, 0x30, 0x39) && firstcode == 0x2d) + ) { + result += '\\' + code.toString(16) + ' '; + } else if( + code >= 0x80 || + code == 0x2d || + code == 0x5f || + between(code, 0x30, 0x39) || + between(code, 0x41, 0x5a) || + between(code, 0x61, 0x7a) + ) { + result += string[i]; + } else { + result += '\\' + string[i]; + } + } + return result; +} + +function escapeHash(string) { + // Escapes the contents of "unrestricted"-type hash tokens. + // Won't preserve the ID-ness of "id"-type hash tokens; + // use escapeIdent() for that. + string = ''+string; + var result = ''; + var firstcode = string.charCodeAt(0); + for(var i = 0; i < string.length; i++) { + var code = string.charCodeAt(i); + if(code == 0x0) { + throw new InvalidCharacterError('Invalid character: the input contains U+0000.'); + } + + if( + code >= 0x80 || + code == 0x2d || + code == 0x5f || + between(code, 0x30, 0x39) || + between(code, 0x41, 0x5a) || + between(code, 0x61, 0x7a) + ) { + result += string[i]; + } else { + result += '\\' + code.toString(16) + ' '; + } + } + return result; +} + +function escapeString(string) { + string = ''+string; + var result = ''; + for(var i = 0; i < string.length; i++) { + var code = string.charCodeAt(i); + + if(code == 0x0) { + throw new InvalidCharacterError('Invalid character: the input contains U+0000.'); + } + + if(between(code, 0x1, 0x1f) || code == 0x7f) { + result += '\\' + code.toString(16) + ' '; + } else if(code == 0x22 || code == 0x5c) { + result += '\\' + string[i]; + } else { + result += string[i]; + } + } + return result; +} + +// Exportation. +exports.tokenize = tokenize; +exports.IdentToken = IdentToken; +exports.FunctionToken = FunctionToken; +exports.AtKeywordToken = AtKeywordToken; +exports.HashToken = HashToken; +exports.StringToken = StringToken; +exports.BadStringToken = BadStringToken; +exports.URLToken = URLToken; +exports.BadURLToken = BadURLToken; +exports.DelimToken = DelimToken; +exports.NumberToken = NumberToken; +exports.PercentageToken = PercentageToken; +exports.DimensionToken = DimensionToken; +exports.IncludeMatchToken = IncludeMatchToken; +exports.DashMatchToken = DashMatchToken; +exports.PrefixMatchToken = PrefixMatchToken; +exports.SuffixMatchToken = SuffixMatchToken; +exports.SubstringMatchToken = SubstringMatchToken; +exports.ColumnToken = ColumnToken; +exports.WhitespaceToken = WhitespaceToken; +exports.CDOToken = CDOToken; +exports.CDCToken = CDCToken; +exports.ColonToken = ColonToken; +exports.SemicolonToken = SemicolonToken; +exports.CommaToken = CommaToken; +exports.OpenParenToken = OpenParenToken; +exports.CloseParenToken = CloseParenToken; +exports.OpenSquareToken = OpenSquareToken; +exports.CloseSquareToken = CloseSquareToken; +exports.OpenCurlyToken = OpenCurlyToken; +exports.CloseCurlyToken = CloseCurlyToken; +exports.EOFToken = EOFToken; +exports.CSSParserToken = CSSParserToken; +exports.GroupingToken = GroupingToken; + +})); diff --git a/test/css-parser.spec.ts b/test/css-parser.spec.ts new file mode 100644 index 0000000000..ab4b129c1b --- /dev/null +++ b/test/css-parser.spec.ts @@ -0,0 +1,99 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { it, expect } from './fixtures'; +import * as path from 'path'; + +const { parseCSS, serializeSelector: serialize } = + require(path.join(__dirname, '..', 'lib', 'server', 'common', 'cssParser')); + +it('should parse css', async () => { + expect(serialize(parseCSS('div'))).toBe('div'); + expect(serialize(parseCSS('div.class'))).toBe('div.class'); + expect(serialize(parseCSS('.class'))).toBe('.class'); + expect(serialize(parseCSS('#id'))).toBe('#id'); + expect(serialize(parseCSS('.class#id'))).toBe('.class#id'); + expect(serialize(parseCSS('div#id.class'))).toBe('div#id.class'); + expect(serialize(parseCSS('*'))).toBe('*'); + expect(serialize(parseCSS('*div'))).toBe('*div'); + expect(serialize(parseCSS('div[attr *= foo i]'))).toBe('div[attr *= foo i]'); + expect(serialize(parseCSS('div[attr~="Bar baz" ]'))).toBe('div[attr~="Bar baz" ]'); + expect(serialize(parseCSS(`div [ foo = 'bar' s]`))).toBe(`div [ foo = "bar" s]`); + + expect(serialize(parseCSS(':hover'))).toBe(':hover'); + expect(serialize(parseCSS('div:hover'))).toBe('div:hover'); + expect(serialize(parseCSS('#id:active:hover'))).toBe('#id:active:hover'); + expect(serialize(parseCSS(':dir(ltr)'))).toBe(':dir(ltr)'); + expect(serialize(parseCSS('#foo-bar.cls:nth-child(3n + 10)'))).toBe('#foo-bar.cls:nth-child(3n + 10)'); + expect(serialize(parseCSS(':lang(en)'))).toBe(':lang(en)'); + expect(serialize(parseCSS('*:hover'))).toBe('*:hover'); + + expect(serialize(parseCSS('div span'))).toBe('div span'); + expect(serialize(parseCSS('div>span'))).toBe('div > span'); + expect(serialize(parseCSS('div +span'))).toBe('div + span'); + expect(serialize(parseCSS('div~ span'))).toBe('div ~ span'); + expect(serialize(parseCSS('div >.class #id+ span'))).toBe('div > .class #id + span'); + expect(serialize(parseCSS('div>span+.class'))).toBe('div > span + .class'); + + expect(serialize(parseCSS('div:not(span)'))).toBe('div:not(span)'); + expect(serialize(parseCSS(':not(span)#id'))).toBe('#id:not(span)'); + expect(serialize(parseCSS('div:not(span):hover'))).toBe('div:hover:not(span)'); + expect(serialize(parseCSS('div:has(span):hover'))).toBe('div:hover:has(span)'); + expect(serialize(parseCSS('div:right-of(span):hover'))).toBe('div:hover:right-of(span)'); + expect(serialize(parseCSS(':right-of(span):react(foobar)'))).toBe(':right-of(span):react(foobar)'); + expect(serialize(parseCSS('div:is(span):hover'))).toBe('div:hover:is(span)'); + expect(serialize(parseCSS('div:scope:hover'))).toBe('div:hover:scope()'); + + expect(serialize(parseCSS(':text("foo")'))).toBe(':text(foo)'); + expect(serialize(parseCSS(':text("*")'))).toBe(':text(*)'); + expect(serialize(parseCSS(':text(*)'))).toBe(':text(*)'); + expect(serialize(parseCSS(':text("foo", normalize-space)'))).toBe(':text(foo, normalize-space)'); + expect(serialize(parseCSS(':index(3, div span)'))).toBe(':index(3, div span)'); + expect(serialize(parseCSS(':is(foo, bar>baz.cls+:not(qux))'))).toBe(':is(foo, bar > baz.cls + :not(qux))'); + // expect(serialize(parseCSS(':right-of(div, bar=50)'))).toBe(':right-of(div, bar=50)'); +}); + +it('should throw on malformed css', async () => { + function expectError(selector: string) { + let error = { message: '' }; + try { + parseCSS(selector); + } catch (e) { + error = e; + } + expect(error.message).toContain(`while parsing selector "${selector}"`); + } + + expectError(''); + expectError('.'); + expectError('#'); + expectError('..'); + expectError('#.'); + expectError('.#'); + expectError('[attr='); + expectError(':not(div'); + expectError('div)'); + expectError('()'); + expectError(':not(##)'); + expectError(':not()'); + expectError(':not(.)'); + expectError('div,'); + expectError(',div'); + expectError('div,,span'); + expectError('div > > span'); + expectError('div > > > > span'); + expectError('div >'); +}); diff --git a/test/selectors-css.spec.ts b/test/selectors-css.spec.ts index a2fc9faa4f..9f83efa248 100644 --- a/test/selectors-css.spec.ts +++ b/test/selectors-css.spec.ts @@ -131,3 +131,39 @@ it('should work with numerical id', async ({page, server}) => { const element = await page.$('#\\31\\32\\33'); expect(element).toBeTruthy(); }); + +it('should work with :nth-child', async ({page, server}) => { + await page.goto(server.PREFIX + '/deep-shadow.html'); + expect(await page.$$eval(`css=span:nth-child(odd)`, els => els.length)).toBe(3); + expect(await page.$$eval(`css=span:nth-child(even)`, els => els.length)).toBe(1); + expect(await page.$$eval(`css=span:nth-child(n+1)`, els => els.length)).toBe(4); + expect(await page.$$eval(`css=span:nth-child(n+2)`, els => els.length)).toBe(1); + expect(await page.$$eval(`css=span:nth-child(2n)`, els => els.length)).toBe(1); + expect(await page.$$eval(`css=span:nth-child(2n+1)`, els => els.length)).toBe(3); + expect(await page.$$eval(`css=span:nth-child(-n)`, els => els.length)).toBe(0); + expect(await page.$$eval(`css=span:nth-child(-n+1)`, els => els.length)).toBe(3); + expect(await page.$$eval(`css=span:nth-child(-n+2)`, els => els.length)).toBe(4); + expect(await page.$$eval(`css=span:nth-child(23n+2)`, els => els.length)).toBe(1); +}); + +it('should work with :not', async ({page, server}) => { + await page.goto(server.PREFIX + '/deep-shadow.html'); + expect(await page.$$eval(`css=div:not(#root1)`, els => els.length)).toBe(2); + expect(await page.$$eval(`css=body :not(span)`, els => els.length)).toBe(4); + expect(await page.$$eval(`css=div > :not(span):not(div)`, els => els.length)).toBe(0); +}); + +it('should work with spaces in :nth-child and :not', test => { + test.fixme('Our selector parser is broken'); +}, async ({page, server}) => { + await page.goto(server.PREFIX + '/deep-shadow.html'); + expect(await page.$$eval(`css=span:nth-child(23n +2)`, els => els.length)).toBe(1); + expect(await page.$$eval(`css=span:nth-child(23n+ 2)`, els => els.length)).toBe(1); + expect(await page.$$eval(`css=span:nth-child( 23n + 2 )`, els => els.length)).toBe(1); + expect(await page.$$eval(`css=span:not(#root1 #target)`, els => els.length)).toBe(3); + expect(await page.$$eval(`css=span:not(:not(#root1 #target))`, els => els.length)).toBe(1); + expect(await page.$$eval(`css=span:not(span:not(#root1 #target))`, els => els.length)).toBe(1); + expect(await page.$$eval(`css=div > :not(span)`, els => els.length)).toBe(2); + expect(await page.$$eval(`css=body :not(span, div)`, els => els.length)).toBe(1); + expect(await page.$$eval(`css=span, section:not(span, div)`, els => els.length)).toBe(5); +}); diff --git a/tsconfig.json b/tsconfig.json index 8b6528ed5a..4606fdec4c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,9 +7,10 @@ "rootDir": "./src", "outDir": "./lib", "strict": true, + "allowJs": true, "declaration": false }, "compileOnSave": true, - "include": ["src/**/*.ts"], - "exclude": ["node_modules"] + "include": ["src/**/*.ts", "src/**/*.js"], + "exclude": ["node_modules", "src/.eslintrc.js"] }