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