RuleSetCompiler.js 8.8 KB

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