Source: diff/patch/DeltaTreeGenerator.js

import {Dsl} from '../../config/Dsl.js';
import {IdExtractor} from '../../extract/IdExtractor.js';
import {Update} from './Update.js';
import {DeltaNode} from './DeltaNode.js';
import {Logger} from '../../util/Logger.js';
import {Node} from '../../tree/Node.js';

/**
 * Generator class for delta trees, i.e. process trees that are annotated with
 * diff-related information
 * @see {DeltaNode}
 */
export class DeltaTreeGenerator {
  /** @type  {DeltaNode} */
  #deltaTree;
  /**
   * A map that assigns each moved node a placeholder node at its old position.
   * @type {Map<DeltaNode, DeltaNode>} */
  #moveMap;

  /**
   * @param {DeltaNode} node
   */
  #applyDelete(node) {
    for (const descendant of node.toPreOrderArray()) {
      descendant.type = Dsl.CHANGE_MODEL.DELETION.label;
    }

    node.removeFromParent();
    node.parent.placeholders.push(node);
  }

  /**
   * @param {DeltaNode} parent
   * @param {DeltaNode} node
   * @param {Number} index
   */
  #applyInsert(parent, node, index) {
    // Copy the inserted node
    const child = DeltaNode.fromNode(node, true);
    parent.insertChild(index, child);
    for (const descendant of child.toPreOrderArray()) {
      descendant.type = Dsl.CHANGE_MODEL.INSERTION.label;
    }
  }

  /**
   * @param {DeltaNode} node
   * @param {Node} newContent
   */
  #applyUpdate(node, newContent) {
    // Detect changes to text content
    if (node.text !== newContent.text) {
      node.updates.set('text', new Update(node.text, newContent.text));
      node.text = newContent.text;
    }

    // Detect updated and inserted attributes
    for (const [key, value] of newContent.attributes) {
      if (node.attributes.has(key)) {
        if (node.attributes.get(key) !== value) {
          node.updates.set(key, new Update(node.attributes.get(key), value));
          node.attributes.set(key, value);
        }
      } else {
        node.updates.set(key, new Update(null, value));
        node.attributes.set(key, value);
      }
    }

    // Detect deleted attributes
    for (const [key, value] of node.attributes) {
      if (!newContent.attributes.has(key)) {
        node.updates.set(key, new Update(value, null));
        node.attributes.delete(key);
      }
    }
  }

  /**
   * Copy a data node and its children and placeholders by value.
   * Any outdated values of the move map are updated in the process.
   * @param {DeltaNode} deltaNode The delta node to copy.
   * @return {DeltaNode}
   */
  #copyAndUpdateMoveMap(deltaNode) {
    const copy = new DeltaNode(
        deltaNode.label,
        deltaNode.text,
        deltaNode.type,
        deltaNode.baseNode,
    );
    const partner =
        [...this.#moveMap.entries()]
            .filter((e) => e[1] === deltaNode)[0];
    if (partner != null) {
      this.#moveMap.set(partner[0], copy);
    }
    for (const [key, value] of deltaNode.attributes) {
      copy.attributes.set(key, value);
    }
    for (const child of deltaNode) {
      copy.appendChild(this.#copyAndUpdateMoveMap(child));
    }
    for (const placeholder of deltaNode.placeholders) {
      copy.placeholders.push(this.#copyAndUpdateMoveMap(placeholder));
    }
    for (const [key, update] of deltaNode.updates) {
      copy.updates.set(key, update.copy());
    }
    return copy;
  };

  /**
   * Create a standard delta tree out of a base tree and an edit script.
   * The standard delta tree does not contain "move from" and "deleted"
   * placeholders.
   * @param {Node} tree The root of the tree to transform into a delta tree.
   * @param {EditScript} editScript The delta.
   * @return {DeltaNode} The root of the delta tree.
   */
  deltaTree(tree, editScript) {
    // Copy the tree as a delta node
    this.#deltaTree = DeltaNode.fromNode(tree, true);
    this.#moveMap = new Map();

    // Node IDs are equal to the index in the pre-order array
    const idExtractor = new IdExtractor();
    for (const node of this.#deltaTree.toPreOrderArray()) {
      node.baseNode = idExtractor.get(node);
    }

    for (const editOp of editScript) {
      switch (editOp.type) {
        case Dsl.CHANGE_MODEL.INSERTION.label: {
          this.#handleInsertion(editOp);
          break;
        }
        case Dsl.CHANGE_MODEL.MOVE.label: {
          this.#handleMove(editOp);
          break;
        }
        case Dsl.CHANGE_MODEL.UPDATE.label: {
          this.#handleUpdate(editOp);
          break;
        }
        case Dsl.CHANGE_MODEL.DELETION.label: {
          this.#handleDeletion(editOp);
          break;
        }
      }
    }
    this.#resolvePlaceholders(this.#deltaTree);
    this.#trim();
    return this.#deltaTree;
  }

  /**
   * @param {String} indexPath
   * @return {Array<DeltaNode>} [regular node, movfrNode].
   */
  #findWithMovfr(indexPath) {
    let currNode = this.#deltaTree;
    let movfrNode = null;
    let foundMovfrNode = false;
    if (indexPath !== '') {
      for (const index of indexPath
          .split('/') // Remove root path "/"
          .map((str) => parseInt(str))) {
        if (index >= currNode.degree()) {
          Logger.error('Edit script not applicable to tree', this);
        }
        if (movfrNode != null) {
          movfrNode = movfrNode.getChild(index);
        }
        currNode = currNode.getChild(index);
        if (this.#moveMap.has(currNode)) {
          movfrNode = this.#moveMap.get(currNode);
          foundMovfrNode = true;
        }
      }
    }
    if (foundMovfrNode && movfrNode == null) {
      Logger.error('Could not find movfr node', this);
    }
    return [
      currNode,
      movfrNode,
    ];
  }

  /**
   * @param {EditOperation} deletion
   */
  #handleDeletion(deletion) {
    const [node, movfrNode] = this.#findWithMovfr(deletion.oldPath);

    this.#applyDelete(node);
    if (movfrNode != null) {
      this.#applyDelete(movfrNode);
    }
  }

  /**
   * @param {EditOperation} insertion
   */
  #handleInsertion(insertion) {
    const indexArr =
        insertion
            .newPath
            .split('/')
            .map((str) => parseInt(str));
    const index = indexArr.pop();
    const [parent, movfrParent] = this.#findWithMovfr(indexArr.join('/'));

    this.#applyInsert(parent, insertion.newContent, index);
    if (movfrParent != null) {
      this.#applyInsert(movfrParent, insertion.newContent, index);
    }
  }

  /**
   * @param {EditOperation} move
   */
  #handleMove(move) {
    // Find moved node
    let [node, movfrNode] = this.#findWithMovfr(move.oldPath);

    // If movfrNode does not exist (no deep move), create a new placeholder
    if (movfrNode == null) {
      // Copy regular node exactly and update moveMap entries
      movfrNode = this.#copyAndUpdateMoveMap(node);
      // Copy regular node index
      movfrNode._index = node.index;
      // Append placeholder
      node.parent.placeholders.push(movfrNode);
    } else {
      movfrNode.removeFromParent();
      movfrNode.parent?.placeholders.push(movfrNode);
    }
    movfrNode.type = Dsl.CHANGE_MODEL.MOVE_FROM.label;

    // Create entry in move map
    this.#moveMap.set(node, movfrNode);

    // Detach regular node
    node.removeFromParent();

    // Find the new parent
    const parentIndexArr =
        move
            .newPath
            .split('/')
            .map((str) => parseInt(str));
    const targetIndex = parentIndexArr.pop();
    const [parent, movfrParent] = this.#findWithMovfr(parentIndexArr.join('/'));

    // Insert dummy node in movfrParent
    if (movfrParent != null) {
      movfrParent.insertChild(targetIndex, new DeltaNode('dummy'));
    }

    // Insert regular node
    parent.insertChild(targetIndex, node);
    node.type = move.type;
  }

  /**
   * @param {EditOperation} update
   */
  #handleUpdate(update) {
    const [node, movfrNode] = this.#findWithMovfr(update.oldPath);
    const newContent = update.newContent;

    this.#applyUpdate(node, newContent);
    if (movfrNode != null) {
      this.#applyUpdate(movfrNode, newContent);
    }
  }

  /**
   * @param {DeltaNode} node
   */
  #resolvePlaceholders(node) {
    for (const child of node.children) {
      this.#resolvePlaceholders(child);
    }
    while (node.placeholders.length > 0) {
      const placeholder = node.placeholders.pop();
      // Placeholders can have placeholders themselves (nested move to front)
      this.#resolvePlaceholders(placeholder);
      node.insertChild(placeholder.index, placeholder);
    }
  }

  /**
   * Trim excess nodes resulting from move operations.
   */
  #trim() {
    for (const deltaNode of this.#deltaTree.toPostOrderArray()) {
      if (deltaNode.label === 'dummy') {
        deltaNode.removeFromParent();
        continue;
      }
      if (deltaNode.isMoved()) {
        deltaNode
            .toPreOrderArray()
            .forEach((descendant) => {
              if (descendant.isMovedFrom()) {
                descendant.removeFromParent();
              }
            });
      } else if (deltaNode.isMovedFrom()) {
        deltaNode
            .toPreOrderArray()
            .forEach((descendant) => {
              if (descendant.isMoved()) {
                descendant.removeFromParent();
              }
            });
      }
    }
  }

  /**
   * Create a trimmed version of the delta tree that does not contain
   * "move-from" or "deleted" placeholders.
   * @param {Node} tree The root of the tree to transform into a delta tree.
   * @param {EditScript} editScript The delta.
   * @return {DeltaNode} The root of the delta tree.
   */
  trimmedDeltaTree(tree, editScript) {
    const deltaTree = this.deltaTree(tree, editScript);
    deltaTree
        .toPreOrderArray()
        .forEach((node) => {
          if (node.isPlaceholder()) {
            node.removeFromParent();
          }
        });
    return deltaTree;
  }
}