UNPKG

8.92 kBJavaScriptView Raw
1/*
2 MIT License http://www.opensource.org/licenses/mit-license.php
3 Author Tobias Koppers @sokra
4*/
5
6"use strict";
7
8const { SyncHook } = require("tapable");
9
10/**
11 * @typedef {Object} RuleCondition
12 * @property {string | string[]} property
13 * @property {boolean} matchWhenEmpty
14 * @property {function(string): boolean} fn
15 */
16
17/**
18 * @typedef {Object} Condition
19 * @property {boolean} matchWhenEmpty
20 * @property {function(string): boolean} fn
21 */
22
23/**
24 * @typedef {Object} CompiledRule
25 * @property {RuleCondition[]} conditions
26 * @property {(Effect|function(object): Effect[])[]} effects
27 * @property {CompiledRule[]=} rules
28 * @property {CompiledRule[]=} oneOf
29 */
30
31/**
32 * @typedef {Object} Effect
33 * @property {string} type
34 * @property {any} value
35 */
36
37/**
38 * @typedef {Object} RuleSet
39 * @property {Map<string, any>} references map of references in the rule set (may grow over time)
40 * @property {function(object): Effect[]} exec execute the rule set
41 */
42
43class RuleSetCompiler {
44 constructor(plugins) {
45 this.hooks = Object.freeze({
46 /** @type {SyncHook<[string, object, Set<string>, CompiledRule, Map<string, any>]>} */
47 rule: new SyncHook([
48 "path",
49 "rule",
50 "unhandledProperties",
51 "compiledRule",
52 "references"
53 ])
54 });
55 if (plugins) {
56 for (const plugin of plugins) {
57 plugin.apply(this);
58 }
59 }
60 }
61
62 /**
63 * @param {object[]} ruleSet raw user provided rules
64 * @returns {RuleSet} compiled RuleSet
65 */
66 compile(ruleSet) {
67 const refs = new Map();
68 const rules = this.compileRules("ruleSet", ruleSet, refs);
69
70 /**
71 * @param {object} data data passed in
72 * @param {CompiledRule} rule the compiled rule
73 * @param {Effect[]} effects an array where effects are pushed to
74 * @returns {boolean} true, if the rule has matched
75 */
76 const execRule = (data, rule, effects) => {
77 for (const condition of rule.conditions) {
78 const p = condition.property;
79 if (Array.isArray(p)) {
80 let current = data;
81 for (const subProperty of p) {
82 if (
83 current &&
84 typeof current === "object" &&
85 Object.prototype.hasOwnProperty.call(current, subProperty)
86 ) {
87 current = current[subProperty];
88 } else {
89 current = undefined;
90 break;
91 }
92 }
93 if (current !== undefined) {
94 if (!condition.fn(current)) return false;
95 continue;
96 }
97 } else if (p in data) {
98 const value = data[p];
99 if (value !== undefined) {
100 if (!condition.fn(value)) return false;
101 continue;
102 }
103 }
104 if (!condition.matchWhenEmpty) {
105 return false;
106 }
107 }
108 for (const effect of rule.effects) {
109 if (typeof effect === "function") {
110 const returnedEffects = effect(data);
111 for (const effect of returnedEffects) {
112 effects.push(effect);
113 }
114 } else {
115 effects.push(effect);
116 }
117 }
118 if (rule.rules) {
119 for (const childRule of rule.rules) {
120 execRule(data, childRule, effects);
121 }
122 }
123 if (rule.oneOf) {
124 for (const childRule of rule.oneOf) {
125 if (execRule(data, childRule, effects)) {
126 break;
127 }
128 }
129 }
130 return true;
131 };
132
133 return {
134 references: refs,
135 exec: data => {
136 /** @type {Effect[]} */
137 const effects = [];
138 for (const rule of rules) {
139 execRule(data, rule, effects);
140 }
141 return effects;
142 }
143 };
144 }
145
146 /**
147 * @param {string} path current path
148 * @param {object[]} rules the raw rules provided by user
149 * @param {Map<string, any>} refs references
150 * @returns {CompiledRule[]} rules
151 */
152 compileRules(path, rules, refs) {
153 return rules.map((rule, i) =>
154 this.compileRule(`${path}[${i}]`, rule, refs)
155 );
156 }
157
158 /**
159 * @param {string} path current path
160 * @param {object} rule the raw rule provided by user
161 * @param {Map<string, any>} refs references
162 * @returns {CompiledRule} normalized and compiled rule for processing
163 */
164 compileRule(path, rule, refs) {
165 const unhandledProperties = new Set(
166 Object.keys(rule).filter(key => rule[key] !== undefined)
167 );
168
169 /** @type {CompiledRule} */
170 const compiledRule = {
171 conditions: [],
172 effects: [],
173 rules: undefined,
174 oneOf: undefined
175 };
176
177 this.hooks.rule.call(path, rule, unhandledProperties, compiledRule, refs);
178
179 if (unhandledProperties.has("rules")) {
180 unhandledProperties.delete("rules");
181 const rules = rule.rules;
182 if (!Array.isArray(rules))
183 throw this.error(path, rules, "Rule.rules must be an array of rules");
184 compiledRule.rules = this.compileRules(`${path}.rules`, rules, refs);
185 }
186
187 if (unhandledProperties.has("oneOf")) {
188 unhandledProperties.delete("oneOf");
189 const oneOf = rule.oneOf;
190 if (!Array.isArray(oneOf))
191 throw this.error(path, oneOf, "Rule.oneOf must be an array of rules");
192 compiledRule.oneOf = this.compileRules(`${path}.oneOf`, oneOf, refs);
193 }
194
195 if (unhandledProperties.size > 0) {
196 throw this.error(
197 path,
198 rule,
199 `Properties ${Array.from(unhandledProperties).join(", ")} are unknown`
200 );
201 }
202
203 return compiledRule;
204 }
205
206 /**
207 * @param {string} path current path
208 * @param {any} condition user provided condition value
209 * @returns {Condition} compiled condition
210 */
211 compileCondition(path, condition) {
212 if (condition === "") {
213 return {
214 matchWhenEmpty: true,
215 fn: str => str === ""
216 };
217 }
218 if (!condition) {
219 throw this.error(
220 path,
221 condition,
222 "Expected condition but got falsy value"
223 );
224 }
225 if (typeof condition === "string") {
226 return {
227 matchWhenEmpty: condition.length === 0,
228 fn: str => typeof str === "string" && str.startsWith(condition)
229 };
230 }
231 if (typeof condition === "function") {
232 try {
233 return {
234 matchWhenEmpty: condition(""),
235 fn: condition
236 };
237 } catch (err) {
238 throw this.error(
239 path,
240 condition,
241 "Evaluation of condition function threw error"
242 );
243 }
244 }
245 if (condition instanceof RegExp) {
246 return {
247 matchWhenEmpty: condition.test(""),
248 fn: v => typeof v === "string" && condition.test(v)
249 };
250 }
251 if (Array.isArray(condition)) {
252 const items = condition.map((c, i) =>
253 this.compileCondition(`${path}[${i}]`, c)
254 );
255 return this.combineConditionsOr(items);
256 }
257
258 if (typeof condition !== "object") {
259 throw this.error(
260 path,
261 condition,
262 `Unexpected ${typeof condition} when condition was expected`
263 );
264 }
265
266 const conditions = [];
267 for (const key of Object.keys(condition)) {
268 const value = condition[key];
269 switch (key) {
270 case "or":
271 if (value) {
272 if (!Array.isArray(value)) {
273 throw this.error(
274 `${path}.or`,
275 condition.and,
276 "Expected array of conditions"
277 );
278 }
279 conditions.push(this.compileCondition(`${path}.or`, value));
280 }
281 break;
282 case "and":
283 if (value) {
284 if (!Array.isArray(value)) {
285 throw this.error(
286 `${path}.and`,
287 condition.and,
288 "Expected array of conditions"
289 );
290 }
291 let i = 0;
292 for (const item of value) {
293 conditions.push(this.compileCondition(`${path}.and[${i}]`, item));
294 i++;
295 }
296 }
297 break;
298 case "not":
299 if (value) {
300 const matcher = this.compileCondition(`${path}.not`, value);
301 const fn = matcher.fn;
302 conditions.push({
303 matchWhenEmpty: !matcher.matchWhenEmpty,
304 fn: v => !fn(v)
305 });
306 }
307 break;
308 default:
309 throw this.error(
310 `${path}.${key}`,
311 condition[key],
312 `Unexpected property ${key} in condition`
313 );
314 }
315 }
316 if (conditions.length === 0) {
317 throw this.error(
318 path,
319 condition,
320 "Expected condition, but got empty thing"
321 );
322 }
323 return this.combineConditionsAnd(conditions);
324 }
325
326 /**
327 * @param {Condition[]} conditions some conditions
328 * @returns {Condition} merged condition
329 */
330 combineConditionsOr(conditions) {
331 if (conditions.length === 0) {
332 return {
333 matchWhenEmpty: false,
334 fn: () => false
335 };
336 } else if (conditions.length === 1) {
337 return conditions[0];
338 } else {
339 return {
340 matchWhenEmpty: conditions.some(c => c.matchWhenEmpty),
341 fn: v => conditions.some(c => c.fn(v))
342 };
343 }
344 }
345
346 /**
347 * @param {Condition[]} conditions some conditions
348 * @returns {Condition} merged condition
349 */
350 combineConditionsAnd(conditions) {
351 if (conditions.length === 0) {
352 return {
353 matchWhenEmpty: false,
354 fn: () => false
355 };
356 } else if (conditions.length === 1) {
357 return conditions[0];
358 } else {
359 return {
360 matchWhenEmpty: conditions.every(c => c.matchWhenEmpty),
361 fn: v => conditions.every(c => c.fn(v))
362 };
363 }
364 }
365
366 /**
367 * @param {string} path current path
368 * @param {any} value value at the error location
369 * @param {string} message message explaining the problem
370 * @returns {Error} an error object
371 */
372 error(path, value, message) {
373 return new Error(
374 `Compiling RuleSet failed: ${message} (at ${path}: ${value})`
375 );
376 }
377}
378
379module.exports = RuleSetCompiler;