// Copyright 2012 Google Inc. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.google.collide.shared.ot; import com.google.collide.dto.DocOp; import com.google.collide.dto.DocOpComponent; import com.google.collide.dto.shared.DocOpFactory; import com.google.collide.json.shared.JsonArray; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; import java.util.Iterator; /* * Derived from Wave's Composer class. We forked it because we have new doc op * components, and removed some of Wave's that aren't applicable. * * The operations being composed are A and B. A occurs before B, so B must * account for A's changes. * * Each of the State subclasses model a possible state during the composing of * the two doc ops. This structure assumes that one of the doc op's current * components is longer lived (for example, spans more characters) than the * other doc op's current component. Given this, each State subclass is just * modeling all the possible combinations. For example, the subclass * ProcessingAForBRetain's responsibility is to keep the current state of the * retain component from the B doc op, and process components from A. As soon as * all of the characters being retained by the componenet from B is finished, * the state will likely flip-flop to ProcessingBForAXxx. */ /** * Composes document operations for the code editor. */ public class Composer { /** * Exception thrown when a composition fails. */ public static class ComposeException extends Exception { private ComposeException(String message, Exception e) { super(message, e); } } /** * Runtime exception used internally by this class. The processing states * implement {@link DocOpCursor} which does not throw these exceptions, so we * model them as runtime exceptions and have an outer catch around the entire * transformation that converts these to the public exception. */ private static class InternalComposeException extends RuntimeException { private InternalComposeException(String message) { super(message); } private InternalComposeException(String message, Throwable t) { super(message, t); } } /** * Base class for any state that is processing A's components. */ private abstract class ProcessingA extends State { /** * Since A occurs before B, B won't have any components that align with A's * delete (B doesn't even know about the text that A deleted.) So, we pass * through the delete without ever touching any of B's components. */ @Override public void delete(String aDeleteText) { output.delete(aDeleteText); } @Override boolean isProcessingB() { return false; } } /** * State that models an outstanding delete component from B. */ private class ProcessingAForBDelete extends ProcessingA { private String bDeleteText; ProcessingAForBDelete(String bDeleteText) { this.bDeleteText = bDeleteText; } @Override public void insert(String aInsertText) { if (aInsertText.length() <= bDeleteText.length()) { cancel(aInsertText.length()); } else { curState = new ProcessingBForAInsert(aInsertText.substring(bDeleteText.length())); } } @Override public void retain(int aRetainCount, boolean aRetainHasTrailingNewline) { if (aRetainCount <= bDeleteText.length()) { output.delete(bDeleteText.substring(0, aRetainCount)); cancel(aRetainCount); } else { output.delete(bDeleteText); curState = new ProcessingBForARetain(aRetainCount - bDeleteText.length(), aRetainHasTrailingNewline); } } @Override public void retainLine(int aRetainLineCount) { // B is modifying a previously retained line output.delete(bDeleteText); if (bDeleteText.endsWith("\n") || isLastComponentOfB) { // B's deletion finishes a line, so A's retain line is affected if (aRetainLineCount == 1) { curState = defaultState; } else { curState = new ProcessingBForARetainLine(aRetainLineCount - 1); } } else { /* * B's deletion is part of a line without finishing it, so A's retain * line is unaffected, we just have to set the state to processing A's * retain line (and so will iterate through B's components) */ curState = new ProcessingBForARetainLine(aRetainLineCount); } } private void cancel(int count) { Preconditions.checkArgument(count <= bDeleteText.length(), "Cannot cancel if A's component is longer than B's"); if (count < bDeleteText.length()) { bDeleteText = bDeleteText.substring(count); } else { curState = defaultState; } } } private class ProcessingAForBRetain extends ProcessingA { private int bRetainCount; private final boolean bRetainHasTrailingNewline; ProcessingAForBRetain(int bRetainCount, boolean bRetainHasTrailingNewline) { this.bRetainCount = bRetainCount; this.bRetainHasTrailingNewline = bRetainHasTrailingNewline; } @Override public void insert(String aInsertText) { if (aInsertText.length() <= bRetainCount) { output.insert(aInsertText); cancel(aInsertText.length()); } else { output.insert(aInsertText.substring(0, bRetainCount)); curState = new ProcessingBForAInsert(aInsertText.substring(bRetainCount)); } } @Override public void retain(int aRetainCount, boolean aRetainHasTrailingNewline) { if (aRetainCount <= bRetainCount) { output.retain(aRetainCount, aRetainHasTrailingNewline); cancel(aRetainCount); } else { output.retain(bRetainCount, bRetainHasTrailingNewline); curState = new ProcessingBForARetain(aRetainCount - bRetainCount, aRetainHasTrailingNewline); } } @Override public void retainLine(int aRetainLineCount) { // B is modifying a previously retained line output.retain(bRetainCount, bRetainHasTrailingNewline); if (bRetainHasTrailingNewline || isLastComponentOfB) { if (aRetainLineCount == 1) { curState = defaultState; } else { curState = new ProcessingBForARetainLine(aRetainLineCount - 1); } } else { curState = new ProcessingBForARetainLine(aRetainLineCount); } } private void cancel(int count) { Preconditions.checkArgument(count <= bRetainCount, "Cannot cancel if A's component is longer than B's"); if (count < bRetainCount) { bRetainCount -= count; } else { curState = defaultState; } } } private class ProcessingAForBRetainLine extends ProcessingA { private int bRetainLineCount; ProcessingAForBRetainLine(int bRetainLineCount) { this.bRetainLineCount = bRetainLineCount; } @Override public void insert(String aInsertText) { // B is retaining the line that A modified output.insert(aInsertText); boolean aInsertTextHasNewline = aInsertText.endsWith("\n"); if (aInsertTextHasNewline || isLastComponentOfA) { cancelLines(1, aInsertTextHasNewline); } } @Override public void retain(int aRetainCount, boolean aRetainHasTrailingNewline) { // B is retaining the line that A modified output.retain(aRetainCount, aRetainHasTrailingNewline); if (aRetainHasTrailingNewline || isLastComponentOfA) { cancelLines(1, aRetainHasTrailingNewline); } } @Override public void retainLine(int aRetainLineCount) { // A and B are retaining some lines int minRetainLineCount = Math.min(aRetainLineCount, bRetainLineCount); output.retainLine(minRetainLineCount); if (aRetainLineCount == bRetainLineCount) { curState = defaultState; } else if (aRetainLineCount == minRetainLineCount) { cancelLines(minRetainLineCount, true); } else if (bRetainLineCount == minRetainLineCount) { curState = new ProcessingBForARetainLine(aRetainLineCount - minRetainLineCount); } } private void cancelLines(int cancelLineCount, boolean hasNewline) { if (hasNewline) { bRetainLineCount -= cancelLineCount; } if (isLastComponentOfA) { transitionForLastComponentOfAAndBRetainLine(bRetainLineCount); } else if (bRetainLineCount == 0) { curState = defaultState; } } } private abstract class ProcessingB extends State { @Override public void insert(String text) { output.insert(text); } @Override boolean isProcessingB() { return true; } } private class ProcessingBForAFinished extends ProcessingB { /** * Tracks whether B has used a retain line component to match any * potentially leftover (unmatched) text on the last line of A. * * A few examples: * <ul> * <li>A is R(2, true), R(5) and B is RL(1), D(2), RL(1). The use of B's * second RL(1) to match the last three characters in A's R(5) would lead to * this variable being set to true.</li> * <li>There is also a potential for this to be true when B's RL is matching * empty text from A. For example, the document text is "Z\n", * A is R(2, true) and B is RL(2). A does not have a component for the * empty-texted last line, but B does (the second line of the RL(2)).</li> * </ul> */ private boolean hasBUsedRlToMatchLeftoverTextOnLastLineOfA; ProcessingBForAFinished(boolean hasBUsedRlToMatchLeftoverTextOnLastLineOfA) { this.hasBUsedRlToMatchLeftoverTextOnLastLineOfA = hasBUsedRlToMatchLeftoverTextOnLastLineOfA; } @Override public void delete(String text) { throw new InternalComposeException("A finished, B cannot have a delete"); } @Override public void retain(int count, boolean hasTrailingNewline) { throw new InternalComposeException("A finished, B cannot have a retain"); } @Override public void retainLine(int lineCount) { if (lineCount == 1 && !hasBUsedRlToMatchLeftoverTextOnLastLineOfA) { output.retainLine(1); hasBUsedRlToMatchLeftoverTextOnLastLineOfA = true; } else { throw new InternalComposeException("A finished, B cannot have a retain line"); } } } private class ProcessingBForAInsert extends ProcessingB { private String aInsertText; ProcessingBForAInsert(String aInsertText) { this.aInsertText = aInsertText; } @Override public void delete(String bDeleteText) { if (bDeleteText.length() <= aInsertText.length()) { cancel(bDeleteText.length()); } else { curState = new ProcessingAForBDelete(bDeleteText.substring(aInsertText.length())); } } @Override public void retain(int bRetainCount, boolean bRetainHasTrailingNewline) { if (bRetainCount <= aInsertText.length()) { output.insert(aInsertText.substring(0, bRetainCount)); cancel(bRetainCount); } else { output.insert(aInsertText); curState = new ProcessingAForBRetain(bRetainCount - aInsertText.length(), bRetainHasTrailingNewline); } } @Override public void retainLine(int bRetainLineCount) { assert bRetainLineCount > 0; // B is retaining the line where A modified output.insert(aInsertText); if (aInsertText.endsWith("\n")) { bRetainLineCount--; } transitionForAInsertOrRetainAndBRetainLine(bRetainLineCount); } private void cancel(int bCount) { if (bCount < aInsertText.length()) { aInsertText = aInsertText.substring(bCount); } else { curState = defaultState; } } } private class ProcessingBForARetain extends ProcessingB { private int aRetainCount; private final boolean aRetainHasTrailingNewline; ProcessingBForARetain(int aRetainCount, boolean aRetainHasTrailingNewline) { this.aRetainCount = aRetainCount; this.aRetainHasTrailingNewline = aRetainHasTrailingNewline; } @Override public void delete(String bDeleteText) { if (bDeleteText.length() <= aRetainCount) { output.delete(bDeleteText); cancel(bDeleteText.length()); } else { output.delete(bDeleteText.substring(0, aRetainCount)); curState = new ProcessingAForBDelete(bDeleteText.substring(aRetainCount)); } } @Override public void retain(int bRetainCount, boolean bRetainHasTrailingNewline) { if (bRetainCount <= this.aRetainCount) { output.retain(bRetainCount, bRetainHasTrailingNewline); cancel(bRetainCount); } else { output.retain(aRetainCount, aRetainHasTrailingNewline); curState = new ProcessingAForBRetain(bRetainCount - aRetainCount, bRetainHasTrailingNewline); } } @Override public void retainLine(int bRetainLineCount) { Preconditions.checkArgument(bRetainLineCount > 0, "Must retain more than one line"); output.retain(aRetainCount, aRetainHasTrailingNewline); if (aRetainHasTrailingNewline) { bRetainLineCount--; } transitionForAInsertOrRetainAndBRetainLine(bRetainLineCount); } private void cancel(int count) { if (count < aRetainCount) { aRetainCount -= count; } else { curState = defaultState; } } } private class ProcessingBForARetainLine extends ProcessingB { private int aRetainLineCount; ProcessingBForARetainLine(int aRetainLineCount) { this.aRetainLineCount = aRetainLineCount; } @Override public void insert(String bInsertText) { super.insert(bInsertText); if (isLastComponentOfB) { cancelLines(1); } } @Override public void delete(String bDeleteText) { // A is retaining the line that B modified output.delete(bDeleteText); if (bDeleteText.endsWith("\n") || isLastComponentOfB) { cancelLines(1); } } @Override public void retain(int bRetainCount, boolean bRetainHasTrailingNewline) { // A is retaining the line that B modified output.retain(bRetainCount, bRetainHasTrailingNewline); if (bRetainHasTrailingNewline || isLastComponentOfB) { cancelLines(1); } } @Override public void retainLine(int bRetainLineCount) { // A and B are retaining some lines int minRetainLineCount = Math.min(aRetainLineCount, bRetainLineCount); output.retainLine(minRetainLineCount); if (aRetainLineCount == bRetainLineCount) { curState = defaultState; } else if (bRetainLineCount == minRetainLineCount) { cancelLines(minRetainLineCount); } else if (aRetainLineCount == minRetainLineCount) { curState = new ProcessingAForBRetainLine(bRetainLineCount - minRetainLineCount); } } private void cancelLines(int cancelLineCount) { aRetainLineCount -= cancelLineCount; if (aRetainLineCount == 0) { curState = defaultState; } } } private static abstract class State implements DocOpCursor { abstract boolean isProcessingB(); } public static DocOp compose(DocOpFactory factory, DocOp a, DocOp b) throws ComposeException { try { return new Composer(factory, a, b).composeImpl(false); } catch (InternalComposeException e) { throw new ComposeException("Could not compose operations:\na: " + DocOpUtils.toString(a, true) + "\nb: " + DocOpUtils.toString(b, true) + "\n", e); } } @VisibleForTesting public static DocOp composeWithStartState(DocOpFactory factory, DocOp a, DocOp b, boolean startWithSpecificProcessingAState) throws ComposeException { try { return new Composer(factory, a, b).composeImpl(startWithSpecificProcessingAState); } catch (InternalComposeException e) { throw new ComposeException("Could not compose operations:\na: " + DocOpUtils.toString(a, true) + "\nb: " + DocOpUtils.toString(b, true) + "\n", e); } } public static DocOp compose(DocOpFactory factory, Iterable<DocOp> docOps) throws ComposeException { Iterator<DocOp> iterator = docOps.iterator(); DocOp prevDocOp = iterator.next(); while (iterator.hasNext()) { prevDocOp = compose(factory, prevDocOp, iterator.next()); } return prevDocOp; } private final DocOp a; private final DocOp b; private final DocOpCapturer output; private final ProcessingA defaultState = new ProcessingA() { @Override public void insert(String aInsertText) { curState = new ProcessingBForAInsert(aInsertText); } @Override public void retain(int aRetainCount, boolean aRetainHasTrailingNewline) { curState = new ProcessingBForARetain(aRetainCount, aRetainHasTrailingNewline); } @Override public void retainLine(int aRetainLineCount) { if (isLastComponentOfB && aRetainLineCount == 1 && isLastComponentOfA) { // This catches the RL(1) that matches nothing // Essentially curState = defaultState; } else { curState = new ProcessingBForARetainLine(aRetainLineCount); } } }; private State curState = defaultState; /** * State for use by processors that is true if A is on its last component. The * last component of A can cancel B's retain line even if A's last component * does not end with a newline or is not a retain line. */ private boolean isLastComponentOfA; /** Similar to {@link #isLastComponentOfA} but for B */ private boolean isLastComponentOfB; private Composer(DocOpFactory factory, DocOp a, DocOp b) { this.a = a; this.b = b; output = new DocOpCapturer(factory, true); } /** * @param startWithSpecificProcessingAState the allows the caller to begin the * compose with an alternate start state. Normally, the first state is * a trivial ProcessingA that just creates a ProcessingBForAXxx. * However, we could also start the compose with a ProcessingAForBXxx. * If true, we will attempt to do the latter. The two paths should * eventually lead to the same solution. */ private DocOp composeImpl(boolean startWithSpecificProcessingAState) { int aIndex = 0; JsonArray<DocOpComponent> aComponents = a.getComponents(); int bIndex = 0; JsonArray<DocOpComponent> bComponents = b.getComponents(); /* * Note the "!= INSERT": There isn't a ProcessingAForBInsert. What that * implementation would like is emit B's insertion, and then flip to * ProcessingBForAXxx, which is what the defaultState will do. */ if (!bComponents.isEmpty() && startWithSpecificProcessingAState && bComponents.get(0).getType() != DocOpComponent.Type.INSERT) { curState = createSpecificProcessingAState(aComponents.get(0), bComponents.get(0)); bIndex++; } else { curState = defaultState; } isLastComponentOfB = bIndex == bComponents.size(); while (aIndex < aComponents.size()) { /* * The state from the previous iteration could be a "processing B for A * finished" which is of type "processing B", but in that case, we would * not have continued to this iteration since the invariant above would * not have passed. */ assert !curState.isProcessingB(); isLastComponentOfA = aIndex == aComponents.size() - 1; DocOpUtils.acceptComponent(aComponents.get(aIndex++), curState); // Notice the different invariant compared to the outer while-loop while (curState.isProcessingB() && !isProcessingBForAFinished()) { if (bIndex >= bComponents.size()) { throw new InternalComposeException("Mismatch in doc ops"); } isLastComponentOfB = bIndex == bComponents.size() - 1; DocOpUtils.acceptComponent(bComponents.get(bIndex++), curState); } /* * At this point, curState must either be processing A, or processing B * after A is finished */ } if (curState != defaultState && !isProcessingBForAFinished() && !isBRetainingRestOfLastLine()) { throw new InternalComposeException("Invalid state"); } if (bIndex < bComponents.size()) { if (curState == defaultState) { curState = new ProcessingBForAFinished(false); } while (bIndex < bComponents.size()) { isLastComponentOfB = bIndex == bComponents.size() - 1; DocOpUtils.acceptComponent(bComponents.get(bIndex++), curState); } } return output.getDocOp(); } private ProcessingA createSpecificProcessingAState(DocOpComponent a, DocOpComponent b) { switch (b.getType()) { case DocOpComponent.Type.DELETE: return new ProcessingAForBDelete(((DocOpComponent.Delete) b).getText()); case DocOpComponent.Type.INSERT: throw new IllegalArgumentException( "Cannot create a specific ProcessingA state for B insertion"); case DocOpComponent.Type.RETAIN: return new ProcessingAForBRetain(((DocOpComponent.Retain) b).getCount(), ((DocOpComponent.Retain) b).hasTrailingNewline()); case DocOpComponent.Type.RETAIN_LINE: return new ProcessingAForBRetainLine(((DocOpComponent.RetainLine) b).getLineCount()); default: throw new IllegalArgumentException("Unknown component type with ordinal: " + b.getType()); } } /** * Trivial method for cleaner syntax at the call sites (no instanceof there) */ private boolean isProcessingBForAFinished() { return curState instanceof ProcessingBForAFinished; } /** * Trivial method for clear syntax at the call sites. */ private boolean isBRetainingRestOfLastLine() { return curState instanceof ProcessingAForBRetainLine && isLastComponentOfA && isLastComponentOfB && ((ProcessingAForBRetainLine) curState).bRetainLineCount == 1; } private void transitionForAInsertOrRetainAndBRetainLine(int remainingBRetainLineCount) { if (isLastComponentOfA) { transitionForLastComponentOfAAndBRetainLine(remainingBRetainLineCount); } else { if (remainingBRetainLineCount == 0) { curState = defaultState; } else { curState = new ProcessingAForBRetainLine(remainingBRetainLineCount); } } } /** * @param remainingBRetainLineCount the remaining retain line count of B * (after any newline that may exist in A) */ private void transitionForLastComponentOfAAndBRetainLine(int remainingBRetainLineCount) { switch (remainingBRetainLineCount) { case 0: curState = new ProcessingBForAFinished(false); break; case 1: curState = new ProcessingBForAFinished(true); break; default: // This is an invalid state curState = new ProcessingAForBRetainLine(remainingBRetainLineCount); break; } } }