1 | /**
|
2 | * @fileoverview The event generator for AST nodes.
|
3 | * @author Toru Nagashima
|
4 | */
|
5 |
|
6 | ;
|
7 |
|
8 | //------------------------------------------------------------------------------
|
9 | // Requirements
|
10 | //------------------------------------------------------------------------------
|
11 |
|
12 | const esquery = require("esquery");
|
13 | const lodash = require("lodash");
|
14 |
|
15 | //------------------------------------------------------------------------------
|
16 | // Typedefs
|
17 | //------------------------------------------------------------------------------
|
18 |
|
19 | /**
|
20 | * An object describing an AST selector
|
21 | * @typedef {Object} ASTSelector
|
22 | * @property {string} rawSelector The string that was parsed into this selector
|
23 | * @property {boolean} isExit `true` if this should be emitted when exiting the node rather than when entering
|
24 | * @property {Object} parsedSelector An object (from esquery) describing the matching behavior of the selector
|
25 | * @property {string[]|null} listenerTypes A list of node types that could possibly cause the selector to match,
|
26 | * or `null` if all node types could cause a match
|
27 | * @property {number} attributeCount The total number of classes, pseudo-classes, and attribute queries in this selector
|
28 | * @property {number} identifierCount The total number of identifier queries in this selector
|
29 | */
|
30 |
|
31 | //------------------------------------------------------------------------------
|
32 | // Helpers
|
33 | //------------------------------------------------------------------------------
|
34 |
|
35 | /**
|
36 | * Gets the possible types of a selector
|
37 | * @param {Object} parsedSelector An object (from esquery) describing the matching behavior of the selector
|
38 | * @returns {string[]|null} The node types that could possibly trigger this selector, or `null` if all node types could trigger it
|
39 | */
|
40 | function getPossibleTypes(parsedSelector) {
|
41 | switch (parsedSelector.type) {
|
42 | case "identifier":
|
43 | return [parsedSelector.value];
|
44 |
|
45 | case "matches": {
|
46 | const typesForComponents = parsedSelector.selectors.map(getPossibleTypes);
|
47 |
|
48 | if (typesForComponents.every(typesForComponent => typesForComponent)) {
|
49 | return lodash.union.apply(null, typesForComponents);
|
50 | }
|
51 | return null;
|
52 | }
|
53 |
|
54 | case "compound": {
|
55 | const typesForComponents = parsedSelector.selectors.map(getPossibleTypes).filter(typesForComponent => typesForComponent);
|
56 |
|
57 | // If all of the components could match any type, then the compound could also match any type.
|
58 | if (!typesForComponents.length) {
|
59 | return null;
|
60 | }
|
61 |
|
62 | /*
|
63 | * If at least one of the components could only match a particular type, the compound could only match
|
64 | * the intersection of those types.
|
65 | */
|
66 | return lodash.intersection.apply(null, typesForComponents);
|
67 | }
|
68 |
|
69 | case "child":
|
70 | case "descendant":
|
71 | case "sibling":
|
72 | case "adjacent":
|
73 | return getPossibleTypes(parsedSelector.right);
|
74 |
|
75 | default:
|
76 | return null;
|
77 |
|
78 | }
|
79 | }
|
80 |
|
81 | /**
|
82 | * Counts the number of class, pseudo-class, and attribute queries in this selector
|
83 | * @param {Object} parsedSelector An object (from esquery) describing the selector's matching behavior
|
84 | * @returns {number} The number of class, pseudo-class, and attribute queries in this selector
|
85 | */
|
86 | function countClassAttributes(parsedSelector) {
|
87 | switch (parsedSelector.type) {
|
88 | case "child":
|
89 | case "descendant":
|
90 | case "sibling":
|
91 | case "adjacent":
|
92 | return countClassAttributes(parsedSelector.left) + countClassAttributes(parsedSelector.right);
|
93 |
|
94 | case "compound":
|
95 | case "not":
|
96 | case "matches":
|
97 | return parsedSelector.selectors.reduce((sum, childSelector) => sum + countClassAttributes(childSelector), 0);
|
98 |
|
99 | case "attribute":
|
100 | case "field":
|
101 | case "nth-child":
|
102 | case "nth-last-child":
|
103 | return 1;
|
104 |
|
105 | default:
|
106 | return 0;
|
107 | }
|
108 | }
|
109 |
|
110 | /**
|
111 | * Counts the number of identifier queries in this selector
|
112 | * @param {Object} parsedSelector An object (from esquery) describing the selector's matching behavior
|
113 | * @returns {number} The number of identifier queries
|
114 | */
|
115 | function countIdentifiers(parsedSelector) {
|
116 | switch (parsedSelector.type) {
|
117 | case "child":
|
118 | case "descendant":
|
119 | case "sibling":
|
120 | case "adjacent":
|
121 | return countIdentifiers(parsedSelector.left) + countIdentifiers(parsedSelector.right);
|
122 |
|
123 | case "compound":
|
124 | case "not":
|
125 | case "matches":
|
126 | return parsedSelector.selectors.reduce((sum, childSelector) => sum + countIdentifiers(childSelector), 0);
|
127 |
|
128 | case "identifier":
|
129 | return 1;
|
130 |
|
131 | default:
|
132 | return 0;
|
133 | }
|
134 | }
|
135 |
|
136 | /**
|
137 | * Compares the specificity of two selector objects, with CSS-like rules.
|
138 | * @param {ASTSelector} selectorA An AST selector descriptor
|
139 | * @param {ASTSelector} selectorB Another AST selector descriptor
|
140 | * @returns {number}
|
141 | * a value less than 0 if selectorA is less specific than selectorB
|
142 | * a value greater than 0 if selectorA is more specific than selectorB
|
143 | * a value less than 0 if selectorA and selectorB have the same specificity, and selectorA <= selectorB alphabetically
|
144 | * a value greater than 0 if selectorA and selectorB have the same specificity, and selectorA > selectorB alphabetically
|
145 | */
|
146 | function compareSpecificity(selectorA, selectorB) {
|
147 | return selectorA.attributeCount - selectorB.attributeCount ||
|
148 | selectorA.identifierCount - selectorB.identifierCount ||
|
149 | (selectorA.rawSelector <= selectorB.rawSelector ? -1 : 1);
|
150 | }
|
151 |
|
152 | /**
|
153 | * Parses a raw selector string, and throws a useful error if parsing fails.
|
154 | * @param {string} rawSelector A raw AST selector
|
155 | * @returns {Object} An object (from esquery) describing the matching behavior of this selector
|
156 | * @throws {Error} An error if the selector is invalid
|
157 | */
|
158 | function tryParseSelector(rawSelector) {
|
159 | try {
|
160 | return esquery.parse(rawSelector.replace(/:exit$/, ""));
|
161 | } catch (err) {
|
162 | if (typeof err.offset === "number") {
|
163 | throw new SyntaxError(`Syntax error in selector "${rawSelector}" at position ${err.offset}: ${err.message}`);
|
164 | }
|
165 | throw err;
|
166 | }
|
167 | }
|
168 |
|
169 | /**
|
170 | * Parses a raw selector string, and returns the parsed selector along with specificity and type information.
|
171 | * @param {string} rawSelector A raw AST selector
|
172 | * @returns {ASTSelector} A selector descriptor
|
173 | */
|
174 | const parseSelector = lodash.memoize(rawSelector => {
|
175 | const parsedSelector = tryParseSelector(rawSelector);
|
176 |
|
177 | return {
|
178 | rawSelector,
|
179 | isExit: rawSelector.endsWith(":exit"),
|
180 | parsedSelector,
|
181 | listenerTypes: getPossibleTypes(parsedSelector),
|
182 | attributeCount: countClassAttributes(parsedSelector),
|
183 | identifierCount: countIdentifiers(parsedSelector)
|
184 | };
|
185 | });
|
186 |
|
187 | //------------------------------------------------------------------------------
|
188 | // Public Interface
|
189 | //------------------------------------------------------------------------------
|
190 |
|
191 | /**
|
192 | * The event generator for AST nodes.
|
193 | * This implements below interface.
|
194 | *
|
195 | * ```ts
|
196 | * interface EventGenerator {
|
197 | * emitter: SafeEmitter;
|
198 | * enterNode(node: ASTNode): void;
|
199 | * leaveNode(node: ASTNode): void;
|
200 | * }
|
201 | * ```
|
202 | */
|
203 | class NodeEventGenerator {
|
204 |
|
205 | /**
|
206 | * @param {SafeEmitter} emitter
|
207 | * An SafeEmitter which is the destination of events. This emitter must already
|
208 | * have registered listeners for all of the events that it needs to listen for.
|
209 | * (See lib/util/safe-emitter.js for more details on `SafeEmitter`.)
|
210 | * @returns {NodeEventGenerator} new instance
|
211 | */
|
212 | constructor(emitter) {
|
213 | this.emitter = emitter;
|
214 | this.currentAncestry = [];
|
215 | this.enterSelectorsByNodeType = new Map();
|
216 | this.exitSelectorsByNodeType = new Map();
|
217 | this.anyTypeEnterSelectors = [];
|
218 | this.anyTypeExitSelectors = [];
|
219 |
|
220 | emitter.eventNames().forEach(rawSelector => {
|
221 | const selector = parseSelector(rawSelector);
|
222 |
|
223 | if (selector.listenerTypes) {
|
224 | selector.listenerTypes.forEach(nodeType => {
|
225 | const typeMap = selector.isExit ? this.exitSelectorsByNodeType : this.enterSelectorsByNodeType;
|
226 |
|
227 | if (!typeMap.has(nodeType)) {
|
228 | typeMap.set(nodeType, []);
|
229 | }
|
230 | typeMap.get(nodeType).push(selector);
|
231 | });
|
232 | } else {
|
233 | (selector.isExit ? this.anyTypeExitSelectors : this.anyTypeEnterSelectors).push(selector);
|
234 | }
|
235 | });
|
236 |
|
237 | this.anyTypeEnterSelectors.sort(compareSpecificity);
|
238 | this.anyTypeExitSelectors.sort(compareSpecificity);
|
239 | this.enterSelectorsByNodeType.forEach(selectorList => selectorList.sort(compareSpecificity));
|
240 | this.exitSelectorsByNodeType.forEach(selectorList => selectorList.sort(compareSpecificity));
|
241 | }
|
242 |
|
243 | /**
|
244 | * Checks a selector against a node, and emits it if it matches
|
245 | * @param {ASTNode} node The node to check
|
246 | * @param {ASTSelector} selector An AST selector descriptor
|
247 | * @returns {void}
|
248 | */
|
249 | applySelector(node, selector) {
|
250 | if (esquery.matches(node, selector.parsedSelector, this.currentAncestry)) {
|
251 | this.emitter.emit(selector.rawSelector, node);
|
252 | }
|
253 | }
|
254 |
|
255 | /**
|
256 | * Applies all appropriate selectors to a node, in specificity order
|
257 | * @param {ASTNode} node The node to check
|
258 | * @param {boolean} isExit `false` if the node is currently being entered, `true` if it's currently being exited
|
259 | * @returns {void}
|
260 | */
|
261 | applySelectors(node, isExit) {
|
262 | const selectorsByNodeType = (isExit ? this.exitSelectorsByNodeType : this.enterSelectorsByNodeType).get(node.type) || [];
|
263 | const anyTypeSelectors = isExit ? this.anyTypeExitSelectors : this.anyTypeEnterSelectors;
|
264 |
|
265 | /*
|
266 | * selectorsByNodeType and anyTypeSelectors were already sorted by specificity in the constructor.
|
267 | * Iterate through each of them, applying selectors in the right order.
|
268 | */
|
269 | let selectorsByTypeIndex = 0;
|
270 | let anyTypeSelectorsIndex = 0;
|
271 |
|
272 | while (selectorsByTypeIndex < selectorsByNodeType.length || anyTypeSelectorsIndex < anyTypeSelectors.length) {
|
273 | if (
|
274 | selectorsByTypeIndex >= selectorsByNodeType.length ||
|
275 | anyTypeSelectorsIndex < anyTypeSelectors.length &&
|
276 | compareSpecificity(anyTypeSelectors[anyTypeSelectorsIndex], selectorsByNodeType[selectorsByTypeIndex]) < 0
|
277 | ) {
|
278 | this.applySelector(node, anyTypeSelectors[anyTypeSelectorsIndex++]);
|
279 | } else {
|
280 | this.applySelector(node, selectorsByNodeType[selectorsByTypeIndex++]);
|
281 | }
|
282 | }
|
283 | }
|
284 |
|
285 | /**
|
286 | * Emits an event of entering AST node.
|
287 | * @param {ASTNode} node - A node which was entered.
|
288 | * @returns {void}
|
289 | */
|
290 | enterNode(node) {
|
291 | if (node.parent) {
|
292 | this.currentAncestry.unshift(node.parent);
|
293 | }
|
294 | this.applySelectors(node, false);
|
295 | }
|
296 |
|
297 | /**
|
298 | * Emits an event of leaving AST node.
|
299 | * @param {ASTNode} node - A node which was left.
|
300 | * @returns {void}
|
301 | */
|
302 | leaveNode(node) {
|
303 | this.applySelectors(node, true);
|
304 | this.currentAncestry.shift();
|
305 | }
|
306 | }
|
307 |
|
308 | module.exports = NodeEventGenerator;
|