function applyRule (node, rule, context) {
    //    console.log('Entering applyRule("', rule.l.toString({parenthesis:'all'}), '->', rule.r.toString({parenthesis:'all'}), '",', node.toString({parenthesis:'all'}),')')

    // check that the assumptions for this rule are satisfied by the current
    // context:
    if (rule.assuming) {
      for (const symbol in rule.assuming) {
        for (const property in rule.assuming[symbol]) {
          if (hasProperty(symbol, property, context) !==
              rule.assuming[symbol][property]) {
            return node
          }
        }
      }
    }

    const mergedContext = mergeContext(rule.imposeContext, context)

    // Do not clone node unless we find a match
    let res = node

    // First replace our child nodes with their simplified versions
    // If a child could not be simplified, applying the rule to it
    // will have no effect since the node is returned unchanged
    if (res instanceof OperatorNode || res instanceof FunctionNode) {
      const newArgs = mapRule(res.args, rule, context)
      if (newArgs !== res.args) {
        res = res.clone()
        res.args = newArgs
      }
    } else if (res instanceof ParenthesisNode) {
      if (res.content) {
        const newContent = applyRule(res.content, rule, context)
        if (newContent !== res.content) {
          res = new ParenthesisNode(newContent)
        }
      }
    } else if (res instanceof ArrayNode) {
      const newItems = mapRule(res.items, rule, context)
      if (newItems !== res.items) {
        res = new ArrayNode(newItems)
      }
    } else if (res instanceof AccessorNode) {
      let newObj = res.object
      if (res.object) {
        newObj = applyRule(res.object, rule, context)
      }
      let newIndex = res.index
      if (res.index) {
        newIndex = applyRule(res.index, rule, context)
      }
      if (newObj !== res.object || newIndex !== res.index) {
        res = new AccessorNode(newObj, newIndex)
      }
    } else if (res instanceof IndexNode) {
      const newDims = mapRule(res.dimensions, rule, context)
      if (newDims !== res.dimensions) {
        res = new IndexNode(newDims)
      }
    } else if (res instanceof ObjectNode) {
      let changed = false
      const newProps = {}
      for (const prop in res.properties) {
        newProps[prop] = applyRule(res.properties[prop], rule, context)
        if (newProps[prop] !== res.properties[prop]) {
          changed = true
        }
      }
      if (changed) {
        res = new ObjectNode(newProps)
      }
    }

    // Try to match a rule against this node
    let repl = rule.r
    let matches = _ruleMatch(rule.l, res, mergedContext)[0]

    // If the rule is associative operator, we can try matching it while allowing additional terms.
    // This allows us to match rules like 'n+n' to the expression '(1+x)+x' or even 'x+1+x' if the operator is commutative.
    if (!matches && rule.expanded) {
      repl = rule.expanded.r
      matches = _ruleMatch(rule.expanded.l, res, mergedContext)[0]
    }
    // Additional, non-commutative context expansion-rules
    if (!matches && rule.expandedNC1) {
      repl = rule.expandedNC1.r
      matches = _ruleMatch(rule.expandedNC1.l, res, mergedContext)[0]
      if (!matches) { // Existence of NC1 implies NC2
        repl = rule.expandedNC2.r
        matches = _ruleMatch(rule.expandedNC2.l, res, mergedContext)[0]
      }
    }

    if (matches) {
      // const before = res.toString({parenthesis: 'all'})

      // Create a new node by cloning the rhs of the matched rule
      // we keep any implicit multiplication state if relevant
      const implicit = res.implicit
      res = repl.clone()
      if (implicit && 'implicit' in repl) {
        res.implicit = true
      }

      // Replace placeholders with their respective nodes without traversing deeper into the replaced nodes
      res = res.transform(function (node) {
        if (node.isSymbolNode && hasOwnProperty(matches.placeholders, node.name)) {
          return matches.placeholders[node.name].clone()
        } else {
          return node
        }
      })

      // const after = res.toString({parenthesis: 'all'})
      // console.log('Simplified ' + before + ' to ' + after)
    }

    if (rule.repeat && res !== node) {
      res = applyRule(res, rule, context)
    }

    return res
  }
Returns a simplfied form of node, or the original node if no simplification was possible.

@param  {ConstantNode | SymbolNode | ParenthesisNode | FunctionNode | OperatorNode} node
@param  {Object | Function} rule
@param  {Object} context -- information about assumed properties of operators
@return {ConstantNode | SymbolNode | ParenthesisNode | FunctionNode | OperatorNode} The simplified form of `expr`, or the original node if no simplification was possible.