tag_utils.js

/**
 * @module tag_utils
 * @description A set of utilities for parsing and testing tags.
 */

const BLANK_STRING_REGEX = /^\s*$/;
const STRING_SPLITTER = /\s+|([\(\)])/g;
const cache = new Map();

/**
 * Given a tag expression and a set of tags, evaluate whether the tags meet
 * the expression or not.
 *
 * @example
 * matcher('luxury | clothing', ['clothing'])
 * => true
 * matcher('luxury | clothing', ['alcohol'])
 * => false
 * matcher('(food | ammo) luxury', ['ammo'])
 * => false
 * matcher('(food | ammo) luxury', ['luxury', 'food'])
 * => true
 *
 * @param {String} expression
 * @param {Set} tags a set of tag strings to match against
 * @returns boolean true if the tags match the expression, false otherwise
 */
export function matcher(expression, tags) {
  if (typeof expression !== "string" || expression === "") {
    return false;
  }
  if (expression === "*") {
    return true;
  }
  let node = cache.get(expression);
  if (!node) {
    node = parser(expression);
    cache.set(expression, node);
  }
  return node.evaluate(Array.from(tags));
}

// exported for tests
export function parser(string) {
  if (typeof string === "undefined" || BLANK_STRING_REGEX.test(string)) {
    return new True();
  }
  if (string instanceof Array) {
    return _parse(string);
  }
  let tokens = string.split(STRING_SPLITTER).filter((el) => el);
  return _parse(tokens);
}

function _parse(array) {
  if (array.length === 0) {
    return new True();
  }
  let someOf = new SomeOf();
  let allOf = new AllOf();
  for (let i = 0, len = array.length; i < len; i++) {
    let element = array[i];
    if (element === "(") {
      let j = seek(array, i);
      let subarray = array.slice(i + 1, j);
      allOf.add(_parse(subarray));
      i = j;
    } else if (element === "|") {
      someOf.add(allOf);
      allOf = new AllOf();
    } else {
      if (element.substring(0, 1) === "-") {
        allOf.add(new Not(element.substring(1)));
      } else if (element !== "&") {
        allOf.add(new Literal(element));
      }
    }
  }
  someOf.add(allOf);
  return someOf.expressions.length === 1 ? someOf.expressions[0] : someOf;
}

function seek(array, i) {
  let indent = 0;
  for (let j = i; j < array.length; j++) {
    if (array[j] === "(") {
      indent++;
    } else if (array[j] === ")") {
      indent--;
    }
    if (i !== j && indent === 0) {
      return j;
    }
  }
  throw new Error("Mismatched parentheses at: " + i);
}

class SetBase {
  constructor() {
    this.expressions = [];
  }
  add(expr) {
    if (expr instanceof SetBase && expr.expressions.length === 1) {
      this.expressions.push(expr.expressions[0]);
    } else {
      this.expressions.push(expr);
    }
  }
}

class SomeOf extends SetBase {
  evaluate(variables) {
    return this.expressions.some((expr) => expr.evaluate(variables));
  }
  toString() {
    return `(${this.expressions.join(" | ")})`;
  }
}

class AllOf extends SetBase {
  evaluate(variables) {
    return this.expressions.every((expr) => expr.evaluate(variables));
  }
  toString() {
    return `(${this.expressions.join(" & ")})`;
  }
}

class Not {
  constructor(token) {
    this.token = token;
  }
  evaluate(variables) {
    return variables.indexOf(this.token) === -1;
  }
  toString() {
    return "-" + this.token;
  }
}

class Literal {
  constructor(token) {
    this.token = token;
  }
  evaluate(variables) {
    return variables.indexOf(this.token) !== -1;
  }
  toString() {
    return this.token;
  }
}

class True {
  evaluate(variables) {
    return true;
  }
  toString() {
    return "true";
  }
}