import { Node, Declaration, Changes, ChangeMap } from "ui/styling/css-selector"; import { isNullOrUndefined } from "utils/types"; import { escapeRegexSymbols } from "utils/utils"; import * as cssParser from "css"; import * as selectorParser from "./css-selector-parser"; const enum Specificity { Inline = 0x01000000, Id = 0x00010000, Attribute = 0x00000100, Class = 0x00000100, PseudoClass = 0x00000100, Type = 0x00000001, Universal = 0x00000000, Invalid = 0x00000000 } const enum Rarity { Invalid = 4, Id = 3, Class = 2, Type = 1, PseudoClass = 0, Attribute = 0, Universal = 0, Inline = 0 } interface LookupSorter { sortById(id: string, sel: SelectorCore); sortByClass(cssClass: string, sel: SelectorCore); sortByType(cssType: string, sel: SelectorCore); sortAsUniversal(sel: SelectorCore); } namespace Match { /** * Depends on attributes or pseudoclasses state; */ export var Dynamic = true; /** * Depends only on the tree structure. */ export var Static = false; } function SelectorProperties(specificity: Specificity, rarity: Rarity, dynamic: boolean = false): ClassDecorator { return cls => { cls.prototype.specificity = specificity; cls.prototype.rarity = rarity; cls.prototype.combinator = ""; cls.prototype.dynamic = dynamic; return cls; } } declare type Combinator = "+" | ">" | "~" | " "; @SelectorProperties(Specificity.Universal, Rarity.Universal, Match.Static) export abstract class SelectorCore { public specificity: number; public rarity: Rarity; public combinator: Combinator; public ruleset: RuleSet; public dynamic: boolean; public abstract match(node: Node): boolean; /** * If the selector is static returns if it matches the node. * If the selector is dynamic returns if it may match the node, and accumulates any changes that may affect its state. */ public abstract accumulateChanges(node: Node, map: ChangeAccumulator): boolean; public lookupSort(sorter: LookupSorter, base?: SelectorCore): void { sorter.sortAsUniversal(base || this); } } export abstract class SimpleSelector extends SelectorCore { public accumulateChanges(node: Node, map?: ChangeAccumulator): boolean { if (!this.dynamic) { return this.match(node); } else if (this.mayMatch(node)) { this.trackChanges(node, map); return true; } return false; } public mayMatch(node: Node): boolean { return this.match(node); } public trackChanges(node: Node, map: ChangeAccumulator): void { // No-op, silence the tslint 'block is empty'. // Some derived classes (dynamic) will actually fill the map with stuff here, some (static) won't do anything. } } function wrap(text: string): string { return text ? ` ${text} ` : ""; } @SelectorProperties(Specificity.Invalid, Rarity.Invalid, Match.Static) export class InvalidSelector extends SimpleSelector { constructor(public e: Error) { super(); } public toString(): string { return ``; } public match(node: Node): boolean { return false; } public lookupSort(sorter: LookupSorter, base?: SelectorCore): void { // No-op, silence the tslint 'block is empty'. // It feels like tslint has problems with simple polymorphism... // This selector is invalid and will never match so we won't bother sorting it to further appear in queries. } } @SelectorProperties(Specificity.Universal, Rarity.Universal, Match.Static) export class UniversalSelector extends SimpleSelector { public toString(): string { return `*${wrap(this.combinator)}`; } public match(node: Node): boolean { return true; } } @SelectorProperties(Specificity.Id, Rarity.Id, Match.Static) export class IdSelector extends SimpleSelector { constructor(public id: string) { super(); } public toString(): string { return `#${this.id}${wrap(this.combinator)}`; } public match(node: Node): boolean { return node.id === this.id; } public lookupSort(sorter: LookupSorter, base?: SelectorCore): void { sorter.sortById(this.id, base || this); } } @SelectorProperties(Specificity.Type, Rarity.Type, Match.Static) export class TypeSelector extends SimpleSelector { constructor(public cssType: string) { super(); } public toString(): string { return `${this.cssType}${wrap(this.combinator)}`; } public match(node: Node): boolean { return node.cssType === this.cssType; } public lookupSort(sorter: LookupSorter, base?: SelectorCore): void { sorter.sortByType(this.cssType, base || this); } } @SelectorProperties(Specificity.Class, Rarity.Class, Match.Static) export class ClassSelector extends SimpleSelector { constructor(public cssClass: string) { super(); } public toString(): string { return `.${this.cssClass}${wrap(this.combinator)}`; } public match(node: Node): boolean { return node.cssClasses && node.cssClasses.has(this.cssClass); } public lookupSort(sorter: LookupSorter, base?: SelectorCore): void { sorter.sortByClass(this.cssClass, base || this); } } declare type AttributeTest = "=" | "^=" | "$=" | "*=" | "=" | "~=" | "|="; @SelectorProperties(Specificity.Attribute, Rarity.Attribute, Match.Dynamic) export class AttributeSelector extends SimpleSelector { constructor(public attribute: string, public test?: AttributeTest, public value?: string) { super(); if (!test) { // HasAttribute this.match = node => !isNullOrUndefined(node[attribute]); return; } if (!value) { this.match = node => false; } let escapedValue = escapeRegexSymbols(value); let regexp: RegExp = null; switch(test) { case "^=": // PrefixMatch regexp = new RegExp("^" + escapedValue); break; case "$=": // SuffixMatch regexp = new RegExp(escapedValue + "$"); break; case "*=": // SubstringMatch regexp = new RegExp(escapedValue); break; case "=": // Equals regexp = new RegExp("^" + escapedValue + "$"); break; case "~=": // Includes if (/\s/.test(value)) { this.match = node => false; return; } regexp = new RegExp("(^|\\s)" + escapedValue + "(\\s|$)"); break; case "|=": // DashMatch regexp = new RegExp("^" + escapedValue + "(-|$)"); break; } if (regexp) { this.match = node => regexp.test(node[attribute] + ""); return; } else { this.match = node => false; return; } } public toString(): string { return `[${this.attribute}${wrap(this.test)}${(this.test && this.value) || ''}]${wrap(this.combinator)}`; } public match(node: Node): boolean { return false; } public mayMatch(node: Node): boolean { return true; } public trackChanges(node: Node, map: ChangeAccumulator): void { map.addAttribute(node, this.attribute); } } @SelectorProperties(Specificity.PseudoClass, Rarity.PseudoClass, Match.Dynamic) export class PseudoClassSelector extends SimpleSelector { constructor(public cssPseudoClass: string) { super(); } public toString(): string { return `:${this.cssPseudoClass}${wrap(this.combinator)}`; } public match(node: Node): boolean { return node.cssPseudoClasses && node.cssPseudoClasses.has(this.cssPseudoClass); } public mayMatch(node: Node): boolean { return true; } public trackChanges(node: Node, map: ChangeAccumulator): void { map.addPseudoClass(node, this.cssPseudoClass); } } export class SimpleSelectorSequence extends SimpleSelector { private head: SimpleSelector; constructor(public selectors: SimpleSelector[]) { super(); this.specificity = selectors.reduce((sum, sel) => sel.specificity + sum, 0); this.head = this.selectors.reduce((prev, curr) => !prev || (curr.rarity > prev.rarity) ? curr : prev, null); this.dynamic = selectors.some(sel => sel.dynamic); } public toString(): string { return `${this.selectors.join("")}${wrap(this.combinator)}`; } public match(node: Node): boolean { return this.selectors.every(sel => sel.match(node)); } public mayMatch(node: Node): boolean { return this.selectors.every(sel => sel.mayMatch(node)); } public trackChanges(node, map): void { this.selectors.forEach(sel => sel.trackChanges(node, map)); } public lookupSort(sorter: LookupSorter, base?: SelectorCore): void { this.head.lookupSort(sorter, base || this); } } export class Selector extends SelectorCore { // Grouped by ancestor combinators, then by direct child combinators. private groups: Selector.ChildGroup[]; private last: SelectorCore; constructor(public selectors: SimpleSelector[]) { super(); let lastGroup: SimpleSelector[]; let groups: SimpleSelector[][] = []; selectors.reverse().forEach(sel => { switch(sel.combinator) { case undefined: case " ": groups.push(lastGroup = []); case ">": lastGroup.push(sel); break; default: throw new Error(`Unsupported combinator "${sel.combinator}".`); } }); this.groups = groups.map(g => new Selector.ChildGroup(g)); this.last = selectors[0]; this.specificity = selectors.reduce((sum, sel) => sel.specificity + sum, 0); this.dynamic = selectors.some(sel => sel.dynamic); } public toString(): string { return this.selectors.join(""); } public match(node: Node): boolean { return this.groups.every((group, i) => { if (i === 0) { node = group.match(node); return !!node; } else { let ancestor = node; while(ancestor = ancestor.parent) { if (node = group.match(ancestor)) { return true; } } return false; } }); } public lookupSort(sorter: LookupSorter, base?: SelectorCore): void { this.last.lookupSort(sorter, this); } public accumulateChanges(node: Node, map?: ChangeAccumulator): boolean { if (!this.dynamic) { return this.match(node); } let bounds: Selector.Bound[] = []; let mayMatch = this.groups.every((group, i) => { if (i === 0) { let nextNode = group.mayMatch(node); bounds.push({ left: node, right: node }); node = nextNode; return !!node; } else { let ancestor = node; while(ancestor = ancestor.parent) { let nextNode = group.mayMatch(ancestor); if (nextNode) { bounds.push({ left: ancestor, right: null }); node = nextNode; return true; } } return false; } }); // Calculating the right bounds for each selectors won't save much if (!mayMatch) { return false; } if (!map) { return mayMatch; } for (let i = 0; i < this.groups.length; i++) { let group = this.groups[i]; if (!group.dynamic) { continue; } let bound = bounds[i]; let node = bound.left; do { if (group.mayMatch(node)) { group.trackChanges(node, map); } } while((node !== bound.right) && (node = node.parent)); } return mayMatch; } } export namespace Selector { // Non-spec. Selector sequences are grouped by ancestor then by child combinators for easier backtracking. export class ChildGroup { public dynamic: boolean; constructor(private selectors: SimpleSelector[]) { this.dynamic = selectors.some(sel => sel.dynamic); } public match(node: Node): Node { return this.selectors.every((sel, i) => (i === 0 ? node : node = node.parent) && sel.match(node)) ? node : null; } public mayMatch(node: Node): Node { return this.selectors.every((sel, i) => (i === 0 ? node : node = node.parent) && sel.mayMatch(node)) ? node : null; } public trackChanges(node: Node, map: ChangeAccumulator) { this.selectors.forEach((sel, i) => (i === 0 ? node : node = node.parent) && sel.trackChanges(node, map)); } } export interface Bound { left: Node; right: Node; } } export class RuleSet { constructor(public selectors: SelectorCore[], private declarations: Declaration[]) { this.selectors.forEach(sel => sel.ruleset = this); } public toString(): string { return `${this.selectors.join(", ")} {${this.declarations.map((d, i) => `${i === 0 ? " ": ""}${d.property}: ${d.value}`).join("; ")} }`; } public lookupSort(sorter: LookupSorter): void { this.selectors.forEach(sel => sel.lookupSort(sorter)); } } export function fromAstNodes(astRules: cssParser.Node[]): RuleSet[] { return (astRules.filter(isRule)).map(rule => { let declarations = rule.declarations.filter(isDeclaration).map(createDeclaration); let selectors = rule.selectors.map(createSelector); let ruleset = new RuleSet(selectors, declarations); return ruleset; }); } function createDeclaration(decl: cssParser.Declaration): any { return { property: decl.property.toLowerCase(), value: decl.value }; } function createSelector(sel: string): SimpleSelector | SimpleSelectorSequence | Selector { try { let ast = selectorParser.parse(sel); if (ast.length === 0) { return new InvalidSelector(new Error("Empty selector")); } let selectors = ast.map(createSimpleSelector); let sequences: (SimpleSelector | SimpleSelectorSequence)[] = []; // Join simple selectors into sequences, set combinators for (let seqStart = 0, seqEnd = 0, last = selectors.length - 1; seqEnd <= last; seqEnd++) { let sel = selectors[seqEnd]; let astComb = ast[seqEnd].comb; if (astComb || seqEnd === last) { if (seqStart === seqEnd) { // This is a sequnce with single SimpleSelector, so we will not combine it into SimpleSelectorSequence. sel.combinator = astComb; sequences.push(sel); } else { let sequence = new SimpleSelectorSequence(selectors.slice(seqStart, seqEnd + 1)); sequence.combinator = astComb; sequences.push(sequence); } seqStart = seqEnd + 1; } } if (sequences.length === 1) { // This is a selector with a single SinmpleSelectorSequence so we will not combine it into Selector. return sequences[0]; } else { return new Selector(sequences); } } catch(e) { return new InvalidSelector(e); } } function createSimpleSelector(sel: selectorParser.SimpleSelector): SimpleSelector { if (selectorParser.isUniversal(sel)) { return new UniversalSelector(); } else if (selectorParser.isId(sel)) { return new IdSelector(sel.ident); } else if (selectorParser.isType(sel)) { return new TypeSelector(sel.ident.replace(/-/, '').toLowerCase()); } else if (selectorParser.isClass(sel)) { return new ClassSelector(sel.ident); } else if (selectorParser.isPseudo(sel)) { return new PseudoClassSelector(sel.ident); } else if (selectorParser.isAttribute(sel)) { if (sel.test) { return new AttributeSelector(sel.prop, sel.test, sel.value); } else { return new AttributeSelector(sel.prop) } } } function isRule(node: cssParser.Node): node is cssParser.Rule { return node.type === "rule"; } function isDeclaration(node: cssParser.Node): node is cssParser.Declaration { return node.type === "declaration"; } interface SelectorInDocument { pos: number; sel: SelectorCore; } interface SelectorMap { [key: string]: SelectorInDocument[] } export class SelectorsMap implements LookupSorter { private id: SelectorMap = {}; private class: SelectorMap = {}; private type: SelectorMap = {}; private universal: SelectorInDocument[] = []; private position = 0; constructor(rulesets: RuleSet[]) { rulesets.forEach(rule => rule.lookupSort(this)); } query(node: T): SelectorsMatch { let selectorClasses = [ this.universal, this.id[node.id], this.type[node.cssType] ]; if (node.cssClasses) { node.cssClasses.forEach(c => selectorClasses.push(this.class[c])); } let selectors = selectorClasses .filter(arr => !!arr) .reduce((cur, next) => cur.concat(next), []); let selectorsMatch = new SelectorsMatch(); selectorsMatch.selectors = selectors .filter(sel => sel.sel.accumulateChanges(node, selectorsMatch)) .sort((a, b) => a.sel.specificity - b.sel.specificity || a.pos - b.pos) .map(docSel => docSel.sel); return selectorsMatch; } sortById(id: string, sel: SelectorCore): void { this.addToMap(this.id, id, sel); } sortByClass(cssClass: string, sel: SelectorCore): void { this.addToMap(this.class, cssClass, sel); } sortByType(cssType: string, sel: SelectorCore): void { this.addToMap(this.type, cssType, sel); } sortAsUniversal(sel: SelectorCore): void { this.universal.push(this.makeDocSelector(sel)); } private addToMap(map: SelectorMap, head: string, sel: SelectorCore): void { this.position++; let list = map[head]; if (list) { list.push(this.makeDocSelector(sel)); } else { map[head] = [this.makeDocSelector(sel)]; } } private makeDocSelector(sel: SelectorCore): SelectorInDocument { return { sel, pos: this.position++ }; } } interface ChangeAccumulator { addAttribute(node: Node, attribute: string): void; addPseudoClass(node: Node, pseudoClass: string): void; } export class SelectorsMatch implements ChangeAccumulator { public changeMap: ChangeMap = new Map(); public selectors; public addAttribute(node: T, attribute: string): void { let deps: Changes = this.properties(node); if (!deps.attributes) { deps.attributes = new Set(); } deps.attributes.add(attribute); } public addPseudoClass(node: T, pseudoClass: string): void { let deps: Changes = this.properties(node); if (!deps.pseudoClasses) { deps.pseudoClasses = new Set(); } deps.pseudoClasses.add(pseudoClass); } private properties(node: T): Changes { let set = this.changeMap.get(node); if (!set) { this.changeMap.set(node, set = {}); } return set; } }