/* * Copyright 2000-2011 JetBrains s.r.o. * * 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.intellij.openapi.editor.impl; import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.editor.TextChange; import com.intellij.util.text.CharArrayCharSequence; import com.intellij.util.text.StringFactory; import org.jetbrains.annotations.NotNull; import java.util.Arrays; import java.util.List; /** * Encapsulates logic of merging set of changes into particular text. * <p/> * Thread-safe. * * @author Denis Zhdanov * @since 12/22/10 12:02 PM */ @SuppressWarnings({"MethodMayBeStatic"}) public class BulkChangesMerger { public static final BulkChangesMerger INSTANCE = new BulkChangesMerger(); private static final Logger LOG = Logger.getInstance("#" + BulkChangesMerger.class.getName()); /** * Merges given changes within the given text and returns result as a new char sequence. * * @param text text to apply given changes for * @param textLength interested number of symbols from the given text to use * @param changes changes to apply to the given text. It's assumed that there are no intersections between them and that they * are sorted by offsets in ascending order * @return merge result */ public CharSequence mergeToCharSequence(@NotNull char[] text, int textLength, @NotNull List<? extends TextChange> changes) { return StringFactory.createShared(mergeToCharArray(text, textLength, changes)); } /** * Merges given changes within the given text and returns result as a new char array. * * @param text text to apply given changes for * @param textLength interested number of symbols from the given text to use * @param changes changes to apply to the given text. It's assumed that there are no intersections between them and that they * are sorted by offsets in ascending order * @return merge result */ public char[] mergeToCharArray(@NotNull char[] text, int textLength, @NotNull List<? extends TextChange> changes) { int newLength = textLength; for (TextChange change : changes) { newLength += change.getText().length() - (change.getEnd() - change.getStart()); } char[] data = new char[newLength]; int oldEndOffset = textLength; int newEndOffset = data.length; for (int i = changes.size() - 1; i >= 0; i--) { TextChange change = changes.get(i); // Copy all unprocessed symbols from initial text that lay after the changed offset. int symbolsToMoveNumber = oldEndOffset - change.getEnd(); System.arraycopy(text, change.getEnd(), data, newEndOffset - symbolsToMoveNumber, symbolsToMoveNumber); newEndOffset -= symbolsToMoveNumber; // Copy all change symbols. char[] changeSymbols = change.getChars(); newEndOffset -= changeSymbols.length; System.arraycopy(changeSymbols, 0, data, newEndOffset, changeSymbols.length); oldEndOffset = change.getStart(); } if (oldEndOffset > 0) { System.arraycopy(text, 0, data, 0, oldEndOffset); } return data; } /** * Allows to perform 'in-place' merge of the given changes to the given array. * <p/> * I.e. it's considered that given array contains particular text at <code>[0; length)</code> region and given changes define * offsets against it. It's also assumed that given array length is enough to contain resulting text after applying the changes. * <p/> * Example: consider that initial text is <code>'12345'</code> and given changes are <code>'remove text at [1; 3) interval'</code> * and <code>'replace text at [4; 5) interval with 'abcde''</code>. Resulting text is <code>'14abcde'</code> then and given array * length should be not less than 7. * * @param data data array * @param length initial text length (without changes) * @param changes change to apply to the target text * @throws IllegalArgumentException if given array is not big enough to contain the resulting text */ public void mergeInPlace(@NotNull char[] data, int length, @NotNull List<? extends TextChangeImpl> changes) throws IllegalArgumentException { // Consider two corner cases: // 1. Every given change increases text length, i.e. change text length is more than changed region length. We can calculate // resulting text length and start merging the changes from the right end then; // 2. Every given change reduces text length, start from the left end then; // The general idea is to group all of the given changes by 'add text'/ 'remove text' criteria and process them sequentially. // Example: let's assume we have the following changes: // 1) replace two symbols with five (diff +3); // 2) replace two symbols by one (diff -1); // 3) replace two symbols by one (diff -1); // 4) replace four symbols by one (diff -3); // 5) replace one symbol by two (diff +2); // 6) replace one symbol by three (diff +2); // Algorithm: // 1. Define the first group of change. First change diff is '+3', hence, iterate all changes until the resulting diff becomes // less or equal to the zero. So, the first four changes conduct the first group. Initial change increased text length, hence, // we process the changes from right to left starting at offset '4-th change start + 1'; // 2. Current diff is '-2' (4-th change diff is '-3' and one slot was necessary for previous group completion), so, that means // that we should process the 4-th and 5-th changes as the second group. Initial change direction is negative, hence, we // process them from left to the right; // 3. Process the remaining change; if (changes.isEmpty()) { return; } int diff = 0; for (TextChangeImpl change : changes) { diff += change.getDiff(); } if (length + diff > data.length) { throw new IllegalArgumentException(String.format( "Can't perform in-place changes merge. Reason: data array is not big enough to hold resulting text. Current size: %d, " + "minimum size: %d", data.length, length + diff )); } try { for (Context context = new Context(changes, data, length, length + diff); !context.isComplete();) { if (!context.startGroup()) { return; } context.endGroup(); } } catch (RuntimeException e) { StringBuilder changesDescription = new StringBuilder(); for (TextChangeImpl change : changes) { changesDescription.append(change.getText().length()).append(":").append(change.getStart()).append("-").append(change.getEnd()) .append(","); } if (changesDescription.length() > 0) { changesDescription.setLength(changesDescription.length() - 1); } LOG.error(String.format( "Invalid attempt to perform in-place document changes merge detected. Initial text length: %d, data array length: %d, " + "changes: [%s], changes diff: %d", length, data.length, changesDescription, diff ), e); char[] merged = mergeToCharArray(data, length, changes); System.arraycopy(merged, 0, data, 0, length + diff); } } private static void copy(@NotNull char[] data, int offset, @NotNull CharSequence text) { for (int i = 0; i < text.length(); i++) { data[i + offset] = text.charAt(i); } } private static class Context { private final List<? extends TextChangeImpl> myChanges; private final char[] myData; private final int myInputLength; private final int myOutputLength; private int myDataStartOffset; private int myDataEndOffset; private int myChangeGroupStartIndex; private int myChangeGroupEndIndex; private int myDiff; private int myFirstChangeShift; private int myLastChangeShift; Context(@NotNull List<? extends TextChangeImpl> changes, @NotNull char[] data, int inputLength, int outputLength) { myChanges = changes; myData = data; myInputLength = inputLength; myOutputLength = outputLength; } /** * Asks current context to update its state in order to point to the first change in a group. * * @return <code>true</code> if the first change in a group is found; <code>false</code> otherwise */ @SuppressWarnings({"ForLoopThatDoesntUseLoopVariable"}) public boolean startGroup() { // Define first change that increases or reduces text length. for (boolean first = true; myDiff == 0 && myChangeGroupStartIndex < myChanges.size(); myChangeGroupStartIndex++, first = false) { TextChangeImpl change = myChanges.get(myChangeGroupStartIndex); myDiff = change.getDiff(); if (first) { myDiff += myFirstChangeShift; } if (myDiff == 0) { copy(myData, change.getStart() + (first ? myFirstChangeShift : 0), change.getText()); } else { myDataStartOffset = change.getStart(); if (first) { myDataStartOffset += myFirstChangeShift; } break; } } return myDiff != 0; } public void endGroup() { boolean includeEndChange = false; myLastChangeShift = 0; for (myChangeGroupEndIndex = myChangeGroupStartIndex + 1; myChangeGroupEndIndex < myChanges.size(); myChangeGroupEndIndex++) { assert myDiff != 0 : String.format( "Text: '%s', length: %d, changes: %s, change group indices: %d-%d", Arrays.toString(myData), myInputLength, myChanges, myChangeGroupStartIndex, myChangeGroupEndIndex); TextChangeImpl change = myChanges.get(myChangeGroupEndIndex); int newDiff = myDiff + change.getDiff(); // Changes group results to the zero text length shift. if (newDiff == 0) { myDataEndOffset = change.getEnd(); includeEndChange = true; break; } // Changes group is not constructed yet. if (!(myDiff > 0 ^ newDiff > 0)) { myDiff = newDiff; continue; } // Current change finishes changes group. myDataEndOffset = change.getStart() + myDiff; myLastChangeShift = myDiff; break; } if (myChangeGroupEndIndex >= myChanges.size()) { if (myDiff > 0) { processLastPositiveGroup(); } else { processLastNegativeGroup(); } myChangeGroupStartIndex = myChangeGroupEndIndex = myChanges.size(); } else if (myDiff > 0) { processPositiveGroup(includeEndChange); } else { processNegativeGroup(includeEndChange); } myDiff = 0; myChangeGroupStartIndex = myChangeGroupEndIndex; if (includeEndChange) { myChangeGroupStartIndex++; } myFirstChangeShift = myLastChangeShift; } /** * Asks to process changes group identified by [{@link #myChangeGroupStartIndex}; {@link #myChangeGroupEndIndex}) where * overall group direction is 'positive' (i.e. it starts from the change that increases text length). * * @param includeEndChange flag that defines if change defined by {@link #myChangeGroupEndIndex} should be processed */ private void processPositiveGroup(boolean includeEndChange) { int outputOffset = myDataEndOffset; int prevChangeStart = -1; for (int i = myChangeGroupEndIndex; i >= myChangeGroupStartIndex; i--) { TextChangeImpl change = myChanges.get(i); if (prevChangeStart >= 0) { int length = prevChangeStart - change.getEnd(); System.arraycopy(myData, change.getEnd(), myData, outputOffset - length, length); outputOffset -= length; } prevChangeStart = change.getStart(); if (i == myChangeGroupEndIndex && !includeEndChange) { continue; } int length = change.getText().length(); if (length > 0) { copy(myData, outputOffset - length, change.getText()); outputOffset -= length; } } } private void processLastPositiveGroup() { int end = myChanges.get(myChanges.size() - 1).getEnd(); int length = myInputLength - end; myDataEndOffset = myOutputLength - length; System.arraycopy(myData, end, myData, myDataEndOffset, length); myChangeGroupEndIndex = myChanges.size() - 1; processPositiveGroup(true); } private void processNegativeGroup(boolean includeEndChange) { int prevChangeEnd = -1; for (int i = myChangeGroupStartIndex; i <= myChangeGroupEndIndex; i++) { TextChangeImpl change = myChanges.get(i); if (prevChangeEnd >= 0) { int length = change.getStart() - prevChangeEnd; System.arraycopy(myData, prevChangeEnd, myData, myDataStartOffset, length); myDataStartOffset += length; } prevChangeEnd = change.getEnd(); if (i == myChangeGroupEndIndex && !includeEndChange) { return; } int length = change.getText().length(); if (length > 0) { copy(myData, myDataStartOffset, change.getText()); myDataStartOffset += length; } } } private void processLastNegativeGroup() { myChangeGroupEndIndex = myChanges.size() - 1; processNegativeGroup(true); int end = myChanges.get(myChangeGroupEndIndex).getEnd(); System.arraycopy(myData, end, myData, myDataStartOffset, myInputLength - end); } public boolean isComplete() { return myChangeGroupStartIndex >= myChanges.size(); } } }