All files / src/dom selector.ts

91.55% Statements 65/71
83.87% Branches 26/31
95.45% Functions 21/22
91.04% Lines 61/67
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172  1x                       7x 7x       20x               2x 2x       2x                   7x 7x 7x 7x 7x       15x 15x   12x   3x                               20x 20x 20x 20x 20x 20x 20x       41x       16x   7x   2x   7x                 1x       16x       36x 16x 6x     20x 20x   20x 41x 21x     20x         16x 20x       20x   17x   3x   1x   1x             1x 1x 2x 1x 1x   1x 1x   1x         1x 1x 3x 2x   1x 1x   1x        
import { DOMNode } from './domnode';
import { Combinator, parseCombinator } from './combinator';
 
class Matcher {
	match(node: DOMNode): boolean { // eslint-disable-line no-unused-vars
		return false;
	}
}
 
class ClassMatcher extends Matcher {
	private readonly classname: string;
 
	constructor(classname: string){
		super();
		this.classname = classname;
	}
 
	match(node: DOMNode): boolean {
		return node.classList.contains(this.classname);
	}
}
 
class IdMatcher extends Matcher {
	private readonly id: string;
 
	constructor(id: string){
		super();
		this.id = id;
	}
 
	match(node: DOMNode): boolean {
		return node.getAttribute('id') === this.id;
	}
}
 
class AttrMatcher extends Matcher {
	private readonly key: string;
	private readonly op: string;
	private readonly value: string;
 
	constructor(attr: string){
		super();
		const [, key, op, value] = attr.match(/^([a-z]+)(?:([~^$*|]?=)"([a-z]+)")?$/);
		this.key = key;
		this.op = op;
		this.value = value;
	}
 
	match(node: DOMNode): boolean {
		const attr = node.getAttribute(this.key);
		switch (this.op){
		case undefined:
			return attr !== null;
		case '=':
			return attr === this.value;
		default:
			// eslint-disable-next-line no-console
			console.error(`Attribute selector operator ${this.op} is not implemented yet`);
			return false;
		}
	}
}
 
class Pattern {
	readonly combinator: Combinator;
	readonly tagName: string;
	private readonly selector: string;
	private readonly pattern: Matcher[];
 
	constructor(pattern: string){
		const match = pattern.match(/^([~+\->]?)((?:[*]|[^.#[]+)?)(.*)$/);
		match.shift(); /* remove full matched string */
		this.selector = pattern;
		this.combinator = parseCombinator(match.shift());
		this.tagName = match.shift() || '*';
		const p = match[0] ? match[0].split(/(?=[.#[])/) : [];
		this.pattern = p.map((cur: string) => Pattern.createMatcher(cur));
	}
 
	match(node: DOMNode): boolean {
		return node.is(this.tagName) && this.pattern.every((cur: Matcher) => cur.match(node));
	}
 
	private static createMatcher(pattern: string): Matcher {
		switch (pattern[0]){
		case '.':
			return new ClassMatcher(pattern.slice(1));
		case '#':
			return new IdMatcher(pattern.slice(1));
		case '[':
			return new AttrMatcher(pattern.slice(1, -1));
		default:
			// eslint-disable-next-line no-console
			console.error(`Failed to create matcher for "${pattern}"`);
			return new Matcher();
		}
	}
}
 
export class Selector {
	private readonly pattern: Pattern[];
 
	constructor(selector: string){
		this.pattern = Selector.parse(selector);
	}
 
	*match(root: DOMNode, level: number = 0): IterableIterator<DOMNode> {
		if (level >= this.pattern.length){
			yield root;
			return;
		}
 
		const pattern = this.pattern[level];
		const matches = Selector.findCandidates(root, pattern);
 
		for (const node of matches){
			if (!pattern.match(node)){
				continue;
			}
 
			yield* this.match(node, level + 1);
		}
	}
 
	private static parse(selector: string): Pattern[] {
		const pattern = selector.replace(/([+~>]) /, '$1').split(/ +/);
		return pattern.map((part: string) => new Pattern(part));
	}
 
	private static findCandidates(root: DOMNode, pattern: Pattern): DOMNode[] {
		switch (pattern.combinator){
		case Combinator.DESCENDANT:
			return root.getElementsByTagName(pattern.tagName);
		case Combinator.CHILD:
			return root.children.filter(node => node.is(pattern.tagName));
		case Combinator.ADJACENT_SIBLING:
			return Selector.findAdjacentSibling(root);
		case Combinator.GENERAL_SIBLING:
			return Selector.findGeneralSibling(root);
		default:
			return [];
		}
	}
 
	private static findAdjacentSibling(node: DOMNode): DOMNode[] {
		let adjacent = false;
		return node.siblings.filter(cur => {
			if (adjacent){
				adjacent = false;
				return true;
			}
			Eif (cur === node){
				adjacent = true;
			}
			return false;
		});
	}
 
	private static findGeneralSibling(node: DOMNode): DOMNode[] {
		let after = false;
		return node.siblings.filter(cur => {
			if (after){
				return true;
			}
			Eif (cur === node){
				after = true;
			}
			return false;
		});
	}
}