1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 | "use strict";
|
7 |
|
8 | const { SyncHook } = require("tapable");
|
9 |
|
10 |
|
11 |
|
12 |
|
13 |
|
14 |
|
15 |
|
16 |
|
17 |
|
18 |
|
19 |
|
20 |
|
21 |
|
22 |
|
23 |
|
24 |
|
25 |
|
26 |
|
27 |
|
28 |
|
29 |
|
30 |
|
31 |
|
32 |
|
33 |
|
34 |
|
35 |
|
36 |
|
37 |
|
38 |
|
39 |
|
40 |
|
41 |
|
42 |
|
43 | class RuleSetCompiler {
|
44 | constructor(plugins) {
|
45 | this.hooks = Object.freeze({
|
46 |
|
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 |
|
64 |
|
65 |
|
66 | compile(ruleSet) {
|
67 | const refs = new Map();
|
68 | const rules = this.compileRules("ruleSet", ruleSet, refs);
|
69 |
|
70 | |
71 |
|
72 |
|
73 |
|
74 |
|
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 |
|
137 | const effects = [];
|
138 | for (const rule of rules) {
|
139 | execRule(data, rule, effects);
|
140 | }
|
141 | return effects;
|
142 | }
|
143 | };
|
144 | }
|
145 |
|
146 | |
147 |
|
148 |
|
149 |
|
150 |
|
151 |
|
152 | compileRules(path, rules, refs) {
|
153 | return rules.map((rule, i) =>
|
154 | this.compileRule(`${path}[${i}]`, rule, refs)
|
155 | );
|
156 | }
|
157 |
|
158 | |
159 |
|
160 |
|
161 |
|
162 |
|
163 |
|
164 | compileRule(path, rule, refs) {
|
165 | const unhandledProperties = new Set(
|
166 | Object.keys(rule).filter(key => rule[key] !== undefined)
|
167 | );
|
168 |
|
169 |
|
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 |
|
208 |
|
209 |
|
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 |
|
328 |
|
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 |
|
348 |
|
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 |
|
368 |
|
369 |
|
370 |
|
371 |
|
372 | error(path, value, message) {
|
373 | return new Error(
|
374 | `Compiling RuleSet failed: ${message} (at ${path}: ${value})`
|
375 | );
|
376 | }
|
377 | }
|
378 |
|
379 | module.exports = RuleSetCompiler;
|