function _ruleMatch (rule, node, context, isSplit) {
    //    console.log('Entering _ruleMatch(' + JSON.stringify(rule) + ', ' + JSON.stringify(node) + ')')
    //    console.log('rule = ' + rule)
    //    console.log('node = ' + node)

    //    console.log('Entering _ruleMatch(', rule.toString({parenthesis:'all'}), ', ', node.toString({parenthesis:'all'}), ', ', context, ')')
    let res = [{ placeholders: {} }]

    if ((rule instanceof OperatorNode && node instanceof OperatorNode) ||
      (rule instanceof FunctionNode && node instanceof FunctionNode)) {
      // If the rule is an OperatorNode or a FunctionNode, then node must match exactly
      if (rule instanceof OperatorNode) {
        if (rule.op !== node.op || rule.fn !== node.fn) {
          return []
        }
      } else if (rule instanceof FunctionNode) {
        if (rule.name !== node.name) {
          return []
        }
      }

      // rule and node match. Search the children of rule and node.
      if ((node.args.length === 1 && rule.args.length === 1) ||
          (!isAssociative(node, context) &&
           node.args.length === rule.args.length) ||
          isSplit) {
        // Expect non-associative operators to match exactly,
        // except in any order if operator is commutative
        let childMatches = []
        for (let i = 0; i < rule.args.length; i++) {
          const childMatch = _ruleMatch(rule.args[i], node.args[i], context)
          if (childMatch.length === 0) {
            // Child did not match, so stop searching immediately
            break
          }
          // The child matched, so add the information returned from the child to our result
          childMatches.push(childMatch)
        }
        if (childMatches.length !== rule.args.length) {
          if (!isCommutative(node, context) || // exact match in order needed
              rule.args.length === 1) { // nothing to commute
            return []
          }
          if (rule.args.length > 2) {
            /* Need to generate all permutations and try them.
             * It's a bit complicated, and unlikely to come up since there
             * are very few ternary or higher operators. So punt for now.
             */
            throw new Error('permuting >2 commutative non-associative rule arguments not yet implemented')
          }
          /* Exactly two arguments, try them reversed */
          const leftMatch = _ruleMatch(rule.args[0], node.args[1], context)
          if (leftMatch.length === 0) {
            return []
          }
          const rightMatch = _ruleMatch(rule.args[1], node.args[0], context)
          if (rightMatch.length === 0) {
            return []
          }
          childMatches = [leftMatch, rightMatch]
        }
        res = mergeChildMatches(childMatches)
      } else if (node.args.length >= 2 && rule.args.length === 2) { // node is flattened, rule is not
        // Associative operators/functions can be split in different ways so we check if the rule
        // matches for each of them and return their union.
        const splits = getSplits(node, context)
        let splitMatches = []
        for (let i = 0; i < splits.length; i++) {
          const matchSet = _ruleMatch(rule, splits[i], context, true) // recursing at the same tree depth here
          splitMatches = splitMatches.concat(matchSet)
        }
        return splitMatches
      } else if (rule.args.length > 2) {
        throw Error('Unexpected non-binary associative function: ' + rule.toString())
      } else {
        // Incorrect number of arguments in rule and node, so no match
        return []
      }
    } else if (rule instanceof SymbolNode) {
      // If the rule is a SymbolNode, then it carries a special meaning
      // according to the first one or two characters of the symbol node name.
      // These meanings are expalined in the documentation for simplify()
      if (rule.name.length === 0) {
        throw new Error('Symbol in rule has 0 length...!?')
      }
      if (SUPPORTED_CONSTANTS[rule.name]) {
        // built-in constant must match exactly
        if (rule.name !== node.name) {
          return []
        }
      } else {
        // wildcards are composed of up to two alphabetic or underscore characters
        switch (rule.name[1] >= 'a' && rule.name[1] <= 'z' ? rule.name.substring(0, 2) : rule.name[0]) {
          case 'n':
          case '_p':
            // rule matches _anything_, so assign this node to the rule.name placeholder
            // Assign node to the rule.name placeholder.
            // Our parent will check for matches among placeholders.
            res[0].placeholders[rule.name] = node
            break
          case 'c':
          case 'cl':
            // rule matches a ConstantNode
            if (isConstantNode(node)) {
              res[0].placeholders[rule.name] = node
            } else {
              // mis-match: rule does not encompass current node
              return []
            }
            break
          case 'v':
            // rule matches anything other than a ConstantNode
            if (!isConstantNode(node)) {
              res[0].placeholders[rule.name] = node
            } else {
              // mis-match: rule does not encompass current node
              return []
            }
            break
          case 'vl':
            // rule matches VariableNode
            if (isVariableNode(node)) {
              res[0].placeholders[rule.name] = node
            } else {
              // mis-match: rule does not encompass current node
              return []
            }
            break
          case 'cd':
            // rule matches a ConstantNode or unaryMinus-wrapped ConstantNode
            if (isNumericNode(node)) {
              res[0].placeholders[rule.name] = node
            } else {
              // mis-match: rule does not encompass current node
              return []
            }
            break
          case 'vd':
            // rule matches anything other than a ConstantNode or unaryMinus-wrapped ConstantNode
            if (!isNumericNode(node)) {
              res[0].placeholders[rule.name] = node
            } else {
              // mis-match: rule does not encompass current node
              return []
            }
            break
          case 'ce':
            // rule matches expressions that have a constant value
            if (isConstantExpression(node)) {
              res[0].placeholders[rule.name] = node
            } else {
              // mis-match: rule does not encompass current node
              return []
            }
            break
          case 've':
            // rule matches expressions that do not have a constant value
            if (!isConstantExpression(node)) {
              res[0].placeholders[rule.name] = node
            } else {
              // mis-match: rule does not encompass current node
              return []
            }
            break
          default:
            throw new Error('Invalid symbol in rule: ' + rule.name)
        }
      }
    } else if (rule instanceof ConstantNode) {
      // Literal constant must match exactly
      if (!equal(rule.value, node.value)) {
        return []
      }
    } else {
      // Some other node was encountered which we aren't prepared for, so no match
      return []
    }

    // It's a match!

    // console.log('_ruleMatch(' + rule.toString() + ', ' + node.toString() + ') found a match')
    return res
  }
Determines whether node matches rule.

@param {ConstantNode | SymbolNode | ParenthesisNode | FunctionNode | OperatorNode} rule
@param {ConstantNode | SymbolNode | ParenthesisNode | FunctionNode | OperatorNode} node
@param {Object} context -- provides assumed properties of operators
@param {Boolean} isSplit -- whether we are in process of splitting an
                   n-ary operator node into possible binary combinations.
                   Defaults to false.
@return {Object} Information about the match, if it exists.