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