You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
	
	
		
			380 lines
		
	
	
		
			8.7 KiB
		
	
	
	
		
			JavaScript
		
	
		
		
			
		
	
	
			380 lines
		
	
	
		
			8.7 KiB
		
	
	
	
		
			JavaScript
		
	
| 
											9 months ago
										 | /* | ||
|  | 	MIT License http://www.opensource.org/licenses/mit-license.php
 | ||
|  | 	Author Tobias Koppers @sokra | ||
|  | */ | ||
|  | 
 | ||
|  | "use strict"; | ||
|  | 
 | ||
|  | const { SyncHook } = require("tapable"); | ||
|  | 
 | ||
|  | /** | ||
|  |  * @typedef {Object} RuleCondition | ||
|  |  * @property {string | string[]} property | ||
|  |  * @property {boolean} matchWhenEmpty | ||
|  |  * @property {function(string): boolean} fn | ||
|  |  */ | ||
|  | 
 | ||
|  | /** | ||
|  |  * @typedef {Object} Condition | ||
|  |  * @property {boolean} matchWhenEmpty | ||
|  |  * @property {function(string): boolean} fn | ||
|  |  */ | ||
|  | 
 | ||
|  | /** | ||
|  |  * @typedef {Object} CompiledRule | ||
|  |  * @property {RuleCondition[]} conditions | ||
|  |  * @property {(Effect|function(object): Effect[])[]} effects | ||
|  |  * @property {CompiledRule[]=} rules | ||
|  |  * @property {CompiledRule[]=} oneOf | ||
|  |  */ | ||
|  | 
 | ||
|  | /** | ||
|  |  * @typedef {Object} Effect | ||
|  |  * @property {string} type | ||
|  |  * @property {any} value | ||
|  |  */ | ||
|  | 
 | ||
|  | /** | ||
|  |  * @typedef {Object} RuleSet | ||
|  |  * @property {Map<string, any>} references map of references in the rule set (may grow over time) | ||
|  |  * @property {function(object): Effect[]} exec execute the rule set | ||
|  |  */ | ||
|  | 
 | ||
|  | class RuleSetCompiler { | ||
|  | 	constructor(plugins) { | ||
|  | 		this.hooks = Object.freeze({ | ||
|  | 			/** @type {SyncHook<[string, object, Set<string>, CompiledRule, Map<string, any>]>} */ | ||
|  | 			rule: new SyncHook([ | ||
|  | 				"path", | ||
|  | 				"rule", | ||
|  | 				"unhandledProperties", | ||
|  | 				"compiledRule", | ||
|  | 				"references" | ||
|  | 			]) | ||
|  | 		}); | ||
|  | 		if (plugins) { | ||
|  | 			for (const plugin of plugins) { | ||
|  | 				plugin.apply(this); | ||
|  | 			} | ||
|  | 		} | ||
|  | 	} | ||
|  | 
 | ||
|  | 	/** | ||
|  | 	 * @param {object[]} ruleSet raw user provided rules | ||
|  | 	 * @returns {RuleSet} compiled RuleSet | ||
|  | 	 */ | ||
|  | 	compile(ruleSet) { | ||
|  | 		const refs = new Map(); | ||
|  | 		const rules = this.compileRules("ruleSet", ruleSet, refs); | ||
|  | 
 | ||
|  | 		/** | ||
|  | 		 * @param {object} data data passed in | ||
|  | 		 * @param {CompiledRule} rule the compiled rule | ||
|  | 		 * @param {Effect[]} effects an array where effects are pushed to | ||
|  | 		 * @returns {boolean} true, if the rule has matched | ||
|  | 		 */ | ||
|  | 		const execRule = (data, rule, effects) => { | ||
|  | 			for (const condition of rule.conditions) { | ||
|  | 				const p = condition.property; | ||
|  | 				if (Array.isArray(p)) { | ||
|  | 					let current = data; | ||
|  | 					for (const subProperty of p) { | ||
|  | 						if ( | ||
|  | 							current && | ||
|  | 							typeof current === "object" && | ||
|  | 							Object.prototype.hasOwnProperty.call(current, subProperty) | ||
|  | 						) { | ||
|  | 							current = current[subProperty]; | ||
|  | 						} else { | ||
|  | 							current = undefined; | ||
|  | 							break; | ||
|  | 						} | ||
|  | 					} | ||
|  | 					if (current !== undefined) { | ||
|  | 						if (!condition.fn(current)) return false; | ||
|  | 						continue; | ||
|  | 					} | ||
|  | 				} else if (p in data) { | ||
|  | 					const value = data[p]; | ||
|  | 					if (value !== undefined) { | ||
|  | 						if (!condition.fn(value)) return false; | ||
|  | 						continue; | ||
|  | 					} | ||
|  | 				} | ||
|  | 				if (!condition.matchWhenEmpty) { | ||
|  | 					return false; | ||
|  | 				} | ||
|  | 			} | ||
|  | 			for (const effect of rule.effects) { | ||
|  | 				if (typeof effect === "function") { | ||
|  | 					const returnedEffects = effect(data); | ||
|  | 					for (const effect of returnedEffects) { | ||
|  | 						effects.push(effect); | ||
|  | 					} | ||
|  | 				} else { | ||
|  | 					effects.push(effect); | ||
|  | 				} | ||
|  | 			} | ||
|  | 			if (rule.rules) { | ||
|  | 				for (const childRule of rule.rules) { | ||
|  | 					execRule(data, childRule, effects); | ||
|  | 				} | ||
|  | 			} | ||
|  | 			if (rule.oneOf) { | ||
|  | 				for (const childRule of rule.oneOf) { | ||
|  | 					if (execRule(data, childRule, effects)) { | ||
|  | 						break; | ||
|  | 					} | ||
|  | 				} | ||
|  | 			} | ||
|  | 			return true; | ||
|  | 		}; | ||
|  | 
 | ||
|  | 		return { | ||
|  | 			references: refs, | ||
|  | 			exec: data => { | ||
|  | 				/** @type {Effect[]} */ | ||
|  | 				const effects = []; | ||
|  | 				for (const rule of rules) { | ||
|  | 					execRule(data, rule, effects); | ||
|  | 				} | ||
|  | 				return effects; | ||
|  | 			} | ||
|  | 		}; | ||
|  | 	} | ||
|  | 
 | ||
|  | 	/** | ||
|  | 	 * @param {string} path current path | ||
|  | 	 * @param {object[]} rules the raw rules provided by user | ||
|  | 	 * @param {Map<string, any>} refs references | ||
|  | 	 * @returns {CompiledRule[]} rules | ||
|  | 	 */ | ||
|  | 	compileRules(path, rules, refs) { | ||
|  | 		return rules | ||
|  | 			.filter(Boolean) | ||
|  | 			.map((rule, i) => this.compileRule(`${path}[${i}]`, rule, refs)); | ||
|  | 	} | ||
|  | 
 | ||
|  | 	/** | ||
|  | 	 * @param {string} path current path | ||
|  | 	 * @param {object} rule the raw rule provided by user | ||
|  | 	 * @param {Map<string, any>} refs references | ||
|  | 	 * @returns {CompiledRule} normalized and compiled rule for processing | ||
|  | 	 */ | ||
|  | 	compileRule(path, rule, refs) { | ||
|  | 		const unhandledProperties = new Set( | ||
|  | 			Object.keys(rule).filter(key => rule[key] !== undefined) | ||
|  | 		); | ||
|  | 
 | ||
|  | 		/** @type {CompiledRule} */ | ||
|  | 		const compiledRule = { | ||
|  | 			conditions: [], | ||
|  | 			effects: [], | ||
|  | 			rules: undefined, | ||
|  | 			oneOf: undefined | ||
|  | 		}; | ||
|  | 
 | ||
|  | 		this.hooks.rule.call(path, rule, unhandledProperties, compiledRule, refs); | ||
|  | 
 | ||
|  | 		if (unhandledProperties.has("rules")) { | ||
|  | 			unhandledProperties.delete("rules"); | ||
|  | 			const rules = rule.rules; | ||
|  | 			if (!Array.isArray(rules)) | ||
|  | 				throw this.error(path, rules, "Rule.rules must be an array of rules"); | ||
|  | 			compiledRule.rules = this.compileRules(`${path}.rules`, rules, refs); | ||
|  | 		} | ||
|  | 
 | ||
|  | 		if (unhandledProperties.has("oneOf")) { | ||
|  | 			unhandledProperties.delete("oneOf"); | ||
|  | 			const oneOf = rule.oneOf; | ||
|  | 			if (!Array.isArray(oneOf)) | ||
|  | 				throw this.error(path, oneOf, "Rule.oneOf must be an array of rules"); | ||
|  | 			compiledRule.oneOf = this.compileRules(`${path}.oneOf`, oneOf, refs); | ||
|  | 		} | ||
|  | 
 | ||
|  | 		if (unhandledProperties.size > 0) { | ||
|  | 			throw this.error( | ||
|  | 				path, | ||
|  | 				rule, | ||
|  | 				`Properties ${Array.from(unhandledProperties).join(", ")} are unknown` | ||
|  | 			); | ||
|  | 		} | ||
|  | 
 | ||
|  | 		return compiledRule; | ||
|  | 	} | ||
|  | 
 | ||
|  | 	/** | ||
|  | 	 * @param {string} path current path | ||
|  | 	 * @param {any} condition user provided condition value | ||
|  | 	 * @returns {Condition} compiled condition | ||
|  | 	 */ | ||
|  | 	compileCondition(path, condition) { | ||
|  | 		if (condition === "") { | ||
|  | 			return { | ||
|  | 				matchWhenEmpty: true, | ||
|  | 				fn: str => str === "" | ||
|  | 			}; | ||
|  | 		} | ||
|  | 		if (!condition) { | ||
|  | 			throw this.error( | ||
|  | 				path, | ||
|  | 				condition, | ||
|  | 				"Expected condition but got falsy value" | ||
|  | 			); | ||
|  | 		} | ||
|  | 		if (typeof condition === "string") { | ||
|  | 			return { | ||
|  | 				matchWhenEmpty: condition.length === 0, | ||
|  | 				fn: str => typeof str === "string" && str.startsWith(condition) | ||
|  | 			}; | ||
|  | 		} | ||
|  | 		if (typeof condition === "function") { | ||
|  | 			try { | ||
|  | 				return { | ||
|  | 					matchWhenEmpty: condition(""), | ||
|  | 					fn: condition | ||
|  | 				}; | ||
|  | 			} catch (err) { | ||
|  | 				throw this.error( | ||
|  | 					path, | ||
|  | 					condition, | ||
|  | 					"Evaluation of condition function threw error" | ||
|  | 				); | ||
|  | 			} | ||
|  | 		} | ||
|  | 		if (condition instanceof RegExp) { | ||
|  | 			return { | ||
|  | 				matchWhenEmpty: condition.test(""), | ||
|  | 				fn: v => typeof v === "string" && condition.test(v) | ||
|  | 			}; | ||
|  | 		} | ||
|  | 		if (Array.isArray(condition)) { | ||
|  | 			const items = condition.map((c, i) => | ||
|  | 				this.compileCondition(`${path}[${i}]`, c) | ||
|  | 			); | ||
|  | 			return this.combineConditionsOr(items); | ||
|  | 		} | ||
|  | 
 | ||
|  | 		if (typeof condition !== "object") { | ||
|  | 			throw this.error( | ||
|  | 				path, | ||
|  | 				condition, | ||
|  | 				`Unexpected ${typeof condition} when condition was expected` | ||
|  | 			); | ||
|  | 		} | ||
|  | 
 | ||
|  | 		const conditions = []; | ||
|  | 		for (const key of Object.keys(condition)) { | ||
|  | 			const value = condition[key]; | ||
|  | 			switch (key) { | ||
|  | 				case "or": | ||
|  | 					if (value) { | ||
|  | 						if (!Array.isArray(value)) { | ||
|  | 							throw this.error( | ||
|  | 								`${path}.or`, | ||
|  | 								condition.and, | ||
|  | 								"Expected array of conditions" | ||
|  | 							); | ||
|  | 						} | ||
|  | 						conditions.push(this.compileCondition(`${path}.or`, value)); | ||
|  | 					} | ||
|  | 					break; | ||
|  | 				case "and": | ||
|  | 					if (value) { | ||
|  | 						if (!Array.isArray(value)) { | ||
|  | 							throw this.error( | ||
|  | 								`${path}.and`, | ||
|  | 								condition.and, | ||
|  | 								"Expected array of conditions" | ||
|  | 							); | ||
|  | 						} | ||
|  | 						let i = 0; | ||
|  | 						for (const item of value) { | ||
|  | 							conditions.push(this.compileCondition(`${path}.and[${i}]`, item)); | ||
|  | 							i++; | ||
|  | 						} | ||
|  | 					} | ||
|  | 					break; | ||
|  | 				case "not": | ||
|  | 					if (value) { | ||
|  | 						const matcher = this.compileCondition(`${path}.not`, value); | ||
|  | 						const fn = matcher.fn; | ||
|  | 						conditions.push({ | ||
|  | 							matchWhenEmpty: !matcher.matchWhenEmpty, | ||
|  | 							fn: v => !fn(v) | ||
|  | 						}); | ||
|  | 					} | ||
|  | 					break; | ||
|  | 				default: | ||
|  | 					throw this.error( | ||
|  | 						`${path}.${key}`, | ||
|  | 						condition[key], | ||
|  | 						`Unexpected property ${key} in condition` | ||
|  | 					); | ||
|  | 			} | ||
|  | 		} | ||
|  | 		if (conditions.length === 0) { | ||
|  | 			throw this.error( | ||
|  | 				path, | ||
|  | 				condition, | ||
|  | 				"Expected condition, but got empty thing" | ||
|  | 			); | ||
|  | 		} | ||
|  | 		return this.combineConditionsAnd(conditions); | ||
|  | 	} | ||
|  | 
 | ||
|  | 	/** | ||
|  | 	 * @param {Condition[]} conditions some conditions | ||
|  | 	 * @returns {Condition} merged condition | ||
|  | 	 */ | ||
|  | 	combineConditionsOr(conditions) { | ||
|  | 		if (conditions.length === 0) { | ||
|  | 			return { | ||
|  | 				matchWhenEmpty: false, | ||
|  | 				fn: () => false | ||
|  | 			}; | ||
|  | 		} else if (conditions.length === 1) { | ||
|  | 			return conditions[0]; | ||
|  | 		} else { | ||
|  | 			return { | ||
|  | 				matchWhenEmpty: conditions.some(c => c.matchWhenEmpty), | ||
|  | 				fn: v => conditions.some(c => c.fn(v)) | ||
|  | 			}; | ||
|  | 		} | ||
|  | 	} | ||
|  | 
 | ||
|  | 	/** | ||
|  | 	 * @param {Condition[]} conditions some conditions | ||
|  | 	 * @returns {Condition} merged condition | ||
|  | 	 */ | ||
|  | 	combineConditionsAnd(conditions) { | ||
|  | 		if (conditions.length === 0) { | ||
|  | 			return { | ||
|  | 				matchWhenEmpty: false, | ||
|  | 				fn: () => false | ||
|  | 			}; | ||
|  | 		} else if (conditions.length === 1) { | ||
|  | 			return conditions[0]; | ||
|  | 		} else { | ||
|  | 			return { | ||
|  | 				matchWhenEmpty: conditions.every(c => c.matchWhenEmpty), | ||
|  | 				fn: v => conditions.every(c => c.fn(v)) | ||
|  | 			}; | ||
|  | 		} | ||
|  | 	} | ||
|  | 
 | ||
|  | 	/** | ||
|  | 	 * @param {string} path current path | ||
|  | 	 * @param {any} value value at the error location | ||
|  | 	 * @param {string} message message explaining the problem | ||
|  | 	 * @returns {Error} an error object | ||
|  | 	 */ | ||
|  | 	error(path, value, message) { | ||
|  | 		return new Error( | ||
|  | 			`Compiling RuleSet failed: ${message} (at ${path}: ${value})` | ||
|  | 		); | ||
|  | 	} | ||
|  | } | ||
|  | 
 | ||
|  | module.exports = RuleSetCompiler; |