blob: 62fdb5153d2e0acf3badbfda7ecfa28f67292266 [file] [log] [blame]
/*
* 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.editor.TextChange;
import com.intellij.util.text.CharArrayUtil;
import com.intellij.util.text.StringFactory;
import org.jetbrains.annotations.NonNls;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* Allows to store and retrieve {@link TextChange} objects assuming that they are applied to the same text.
* <p/>
* Provides ability to automatic merging them if necessary.
* <p/>
* Not thread-safe.
*
* @author Denis Zhdanov
* @since 3/2/11 11:55 AM
*/
public class TextChangesStorage {
private final List<ChangeEntry> myChanges = new ArrayList<ChangeEntry>();
/**
* @return list of changes stored previously via {@link #store(TextChange)}. Note that the changes offsets relate to initial
* text and that returned list is sorted by start offset in ascending order
* @see #store(TextChange)
*/
@NotNull
public List<TextChangeImpl> getChanges() {
if (myChanges.isEmpty()) return Collections.emptyList();
List<TextChangeImpl> result = new ArrayList<TextChangeImpl>(myChanges.size());
for (ChangeEntry changeEntry : myChanges) {
result.add(changeEntry.change);
}
return result;
}
/**
* Allows to ask the storage for the list of changes that have intersections with the target text range (identified by the given
* arguments).
*
* @param start target range start offset (inclusive)
* @param end target range end offset (exclusive)
* @return list that contains all registered changes that have intersections with the target text range
*/
@NotNull
public List<? extends TextChange> getChanges(int start, int end) {
assert start <= end;
int changeStartIndex = getChangeIndex(start);
if (changeStartIndex < 0) {
changeStartIndex = -changeStartIndex - 1;
}
if (changeStartIndex >= myChanges.size()) {
return Collections.emptyList();
}
int changeEndIndex = getChangeIndex(end);
boolean endInclusive = true;
if (changeEndIndex < 0) {
changeEndIndex = -changeEndIndex - 1;
endInclusive = false;
}
List<TextChange> result = null;
for (int i = changeStartIndex; i <= changeEndIndex; i++) {
if (!endInclusive && i == changeEndIndex) {
break;
}
if (result == null) {
result = new ArrayList<TextChange>();
}
result.add(myChanges.get(i).change);
}
return result == null ? Collections.<TextChange>emptyList() : result;
}
public boolean isEmpty() {
return myChanges.isEmpty();
}
public void clear() {
myChanges.clear();
}
public int size() {
return myChanges.size();
}
/**
* Store given change merging it with previously stored ones if necessary.
* <p/>
* <b>Note:</b> it's assumed that given change offsets are related to the current state of the text (<code>'client text'</code>),
* i.e. with all stored changes applied to it. Example:
* <ol>
* <li>Say, we have initial text <code>'12345'</code>;</li>
* <li>
* Suppose the change <code>'replace text at [2; 3) range with 'ABC''</code> is applied to it (stored at the current object).
* End-users see the text <code>'12ABC45'</code> now;
* </li>
* <li>
* This method is called with change like <code>'replace text at [1; 6) range with 'XY''</code>. Change range is assumed to
* be related to the text visible to end-user, not initial one (<code>'12ABC45'</code>, not <code>'12345'</code>).
* I.e. the user will see text <code>'1XY5'</code> now;
* </li>
* </ol>
*
* @param change change to store
*/
public void store(@NotNull TextChange change) {
if (myChanges.isEmpty()) {
myChanges.add(new ChangeEntry(new TextChangeImpl(change.getText(), change.getStart(), change.getEnd()), change.getStart()));
return;
}
// There is a big chance that the document is processed sequentially from start to end, hence, it makes sense
// to check if given change lays beyond other registered changes and register it quickly in case of success.
ChangeEntry last = myChanges.get(myChanges.size() - 1);
if (last.clientStartOffset + last.change.getText().length() < change.getStart()) {
int clientShift = last.clientStartOffset - last.change.getStart() + last.change.getDiff();
myChanges.add(new ChangeEntry(
new TextChangeImpl(change.getText(), change.getStart() - clientShift, change.getEnd() - clientShift),
change.getStart()
));
return;
}
int insertionIndex = doStore(change);
if (insertionIndex < 0) {
return;
}
mergeIfNecessary(insertionIndex);
}
/**
* Stores given change at the current storage and returns its index at {@link #myChanges changes collection} (if any).
*
* @param change change to store
* @return non-negative value that indicates index under which given change is stored at the
* {@link #myChanges changes collection}; negative value if given change only modifies sub-range of
* already registered range
*/
@SuppressWarnings({"AssignmentToForLoopParameter"})
private int doStore(@NotNull TextChange change) {
int newChangeStart = change.getStart();
int newChangeEnd = change.getEnd();
int insertionIndex = getChangeIndex(change.getStart());
int clientShift = 0; // 'Client text' shift before the given change to store. I.e. this value can be subtracted from the
// given change's start/end offsets in order to get original document range affected by the given change.
int changeDiff = change.getText().length() - (change.getEnd() - change.getStart());
if (insertionIndex < 0) {
insertionIndex = -insertionIndex - 1;
if (insertionIndex >= myChanges.size()) {
if (!myChanges.isEmpty()) {
ChangeEntry changeEntry = myChanges.get(myChanges.size() - 1);
clientShift = changeEntry.clientStartOffset - changeEntry.change.getStart() + changeEntry.change.getDiff();
}
myChanges.add(new ChangeEntry(
new TextChangeImpl(change.getText(), change.getStart() - clientShift, change.getEnd() - clientShift),
change.getStart()
));
return insertionIndex;
}
else if (insertionIndex > 0 && !myChanges.isEmpty()) {
ChangeEntry changeEntry = myChanges.get(insertionIndex - 1);
clientShift = changeEntry.clientStartOffset - changeEntry.change.getStart() + changeEntry.change.getDiff();
}
}
else {
ChangeEntry changeEntry = myChanges.get(insertionIndex);
clientShift = changeEntry.clientStartOffset - changeEntry.change.getStart();
}
boolean updateClientOffsetOnly = false;
for (int i = insertionIndex; i < myChanges.size(); i++) {
ChangeEntry changeEntry = myChanges.get(i);
int storedClientStart = changeEntry.change.getStart() + clientShift;
CharSequence storedText = changeEntry.change.getText();
int storedClientEnd = storedClientStart + storedText.length();
// Stored change lays after the new one.
if (!updateClientOffsetOnly && storedClientStart > newChangeEnd) {
if (changeDiff != 0) {
updateClientOffsetOnly = true;
}
else {
break;
}
}
if (updateClientOffsetOnly) {
changeEntry.clientStartOffset += changeDiff;
continue;
}
// Stored change lays before the new one.
if (storedClientEnd <= newChangeStart) {
clientShift += changeEntry.change.getDiff();
insertionIndex = i + 1;
continue;
}
// Check if given change targets sub-range of the stored one.
if (storedClientStart <= newChangeStart && storedClientEnd >= newChangeEnd) {
StringBuilder adjustedText = new StringBuilder();
if (storedClientStart < newChangeStart) {
adjustedText.append(storedText.subSequence(0, newChangeStart - storedClientStart));
}
adjustedText.append(change.getText());
if (storedClientEnd > newChangeEnd) {
adjustedText.append(storedText.subSequence(newChangeEnd - storedClientStart, storedText.length()));
}
if (adjustedText.length() == 0 && changeEntry.change.getStart() == changeEntry.change.getEnd()) {
myChanges.remove(i);
insertionIndex = -1;
updateClientOffsetOnly = true;
i--; // Assuming that 'i' is incremented at the 'for' loop.
continue;
}
changeEntry.change = new TextChangeImpl(adjustedText, changeEntry.change.getStart(), changeEntry.change.getEnd());
insertionIndex = -1;
updateClientOffsetOnly = true;
continue;
}
// Check if given change completely contains stored change range.
if (newChangeStart <= storedClientStart && newChangeEnd >= storedClientEnd) {
myChanges.remove(i);
insertionIndex = i;
newChangeEnd -= changeEntry.change.getDiff();
i--; // Assuming that 'i' is incremented at the 'for' loop.
continue;
}
// Check if given change intersects stored change range from the left.
if (newChangeStart <= storedClientStart && newChangeEnd > storedClientStart) {
int numberOfStoredChangeSymbolsToRemove = newChangeEnd - storedClientStart;
CharSequence adjustedText = storedText.subSequence(numberOfStoredChangeSymbolsToRemove, storedText.length());
changeEntry.change = new TextChangeImpl(adjustedText, changeEntry.change.getStart(), changeEntry.change.getEnd());
changeEntry.clientStartOffset += changeDiff + numberOfStoredChangeSymbolsToRemove;
newChangeEnd -= numberOfStoredChangeSymbolsToRemove;
insertionIndex = i;
continue;
}
// Check if given change intersects stored change range from the right.
if (newChangeStart < storedClientEnd && newChangeEnd >= storedClientEnd) {
CharSequence adjustedText = storedText.subSequence(0, newChangeStart - storedClientStart);
TextChangeImpl adjusted = new TextChangeImpl(adjustedText, changeEntry.change.getStart(), changeEntry.change.getEnd());
changeEntry.change = adjusted;
clientShift += adjusted.getDiff();
newChangeEnd -= storedClientEnd - newChangeStart;
insertionIndex = i + 1;
continue;
}
// Check if given change is left-adjacent to the stored change.
if (newChangeEnd == storedClientStart) {
changeEntry.clientStartOffset += changeDiff;
}
}
if (insertionIndex >= 0) {
myChanges.add(insertionIndex, new ChangeEntry(
new TextChangeImpl(change.getText(), newChangeStart - clientShift, newChangeEnd - clientShift),
change.getStart()
));
}
return insertionIndex;
}
/**
* Merges if necessary change stored at {@link #myChanges changes collection} at the given index with adjacent changes.
*
* @param insertionIndex index of the change that can potentially be merged with adjacent changes
*/
private void mergeIfNecessary(int insertionIndex) {
// Merge with previous if necessary.
ChangeEntry toMerge = myChanges.get(insertionIndex);
if (insertionIndex > 0) {
ChangeEntry left = myChanges.get(insertionIndex - 1);
if (left.getClientEndOffset() == toMerge.clientStartOffset && left.change.getEnd() == toMerge.change.getStart()) {
String text = left.change.getText().toString() + toMerge.change.getText();
left.change = new TextChangeImpl(text, left.change.getStart(), toMerge.change.getEnd());
myChanges.remove(insertionIndex);
insertionIndex--;
}
}
// Merge with next if necessary.
toMerge = myChanges.get(insertionIndex);
if (insertionIndex < myChanges.size() - 1) {
ChangeEntry right = myChanges.get(insertionIndex + 1);
if (toMerge.getClientEndOffset() == right.clientStartOffset && toMerge.change.getEnd() == right.change.getStart()) {
String text = toMerge.change.getText().toString() + right.change.getText();
toMerge.change = new TextChangeImpl(text, toMerge.change.getStart(), right.change.getEnd());
myChanges.remove(insertionIndex + 1);
}
}
}
/**
* Allows to retrieve character for the given index assuming that it should be resolved against 'client text', i.e. the text contained
* at the given original char sequence with all {@link #myChanges registered changes} applied to it.
* <p/>
* Example:
* <pre>
* <ul>
* <li>Consider that original text is <code>'01234'</code>;</li>
* <li>
* Consider that two changes are registered: <code>'insert text 'a' at index 1'</code> and
* <code>'insert text 'bc' at index 3'</code>;
* </li>
* <li><code>'client text'</code> now is <code>'0a12bc34'</code>;</li>
* <li>This method is called with index '5' - symbol 'c' is returned;</li>
* </ul>
* </pre>
*
* @param originalData original text to which {@link #myChanges registered changes} are applied
* @param index target symbol index (is assumed to be 'client text' index)
* @return 'client text' symbol at the given index
*/
public char charAt(@NotNull char[] originalData, int index) {
int changeIndex = getChangeIndex(index);
if (changeIndex >= 0) {
// Target char is contained at the stored change text
ChangeEntry changeEntry = myChanges.get(changeIndex);
if (changeEntry.change.getText().length() > index - changeEntry.clientStartOffset) {
return changeEntry.change.getText().charAt(index - changeEntry.clientStartOffset);
}
else {
int originalArrayIndex = index - (changeEntry.clientStartOffset - changeEntry.change.getStart() + changeEntry.change.getDiff());
return originalData[originalArrayIndex];
}
}
else {
changeIndex = -changeIndex - 1;
int clientShift = 0;
if (changeIndex > 0 && changeIndex <= myChanges.size()) {
ChangeEntry changeEntry = myChanges.get(changeIndex - 1);
clientShift = changeEntry.clientStartOffset - changeEntry.change.getStart() + changeEntry.change.getDiff();
}
return originalData[index - clientShift];
}
}
/**
* Allows to build substring of the client text with its changes registered within the current storage.
*
* @param originalData original text to which {@link #myChanges registered changes} are applied
* @param start target substring start offset (against the 'client text'; inclusive)
* @param end target substring end offset (against the 'client text'; exclusive)
* @return substring for the given text range
*/
public CharSequence substring(@NotNull char[] originalData, int start, int end) {
if (myChanges.isEmpty()) {
return new String(originalData, start, end - start);
}
if (end == start) {
return "";
}
int startChangeIndex = getChangeIndex(start);
int endChangeIndex = getChangeIndex(end);
boolean substringAffectedByChanges = startChangeIndex >= 0 || endChangeIndex >= 0 || startChangeIndex != endChangeIndex;
int clientShift = 0;
int originalStart = 0;
if (startChangeIndex < 0) {
startChangeIndex = -startChangeIndex - 1;
if (startChangeIndex > 0 && startChangeIndex <= myChanges.size()) {
ChangeEntry changeEntry = myChanges.get(startChangeIndex - 1);
clientShift = changeEntry.clientStartOffset - changeEntry.change.getStart() + changeEntry.change.getDiff();
originalStart = changeEntry.change.getEnd();
}
}
else {
ChangeEntry changeEntry = myChanges.get(startChangeIndex);
clientShift = changeEntry.clientStartOffset - changeEntry.change.getStart();
}
if (!substringAffectedByChanges) {
return new String(originalData, start - clientShift, end - start);
}
char[] data = new char[end - start];
int outputOffset = 0;
for (int i = startChangeIndex; i < myChanges.size() && outputOffset < data.length; i++) {
ChangeEntry changeEntry = myChanges.get(i);
int clientStart = changeEntry.clientStartOffset;
if (clientStart >= end) {
if (i == startChangeIndex) {
return new String(originalData, start - clientShift, end - start);
}
System.arraycopy(originalData, originalStart, data, outputOffset, data.length - outputOffset);
break;
}
int clientEnd = clientStart + changeEntry.change.getText().length();
if (clientEnd > start) {
if (clientStart > start) {
int length = Math.min(clientStart - start, changeEntry.change.getStart() - originalStart);
length = Math.min(length, data.length - outputOffset);
System.arraycopy(originalData, changeEntry.change.getStart() - length, data, outputOffset, length);
outputOffset += length;
if (outputOffset >= data.length) {
break;
}
}
if (end >= clientStart && clientStart < clientEnd) {
int changeTextStartOffset = start <= clientStart ? 0 : start - clientStart;
int length = Math.min(clientEnd, end) - Math.max(clientStart, start);
CharArrayUtil.getChars(changeEntry.change.getText(), data, changeTextStartOffset, outputOffset, length);
outputOffset += length;
}
}
originalStart = changeEntry.change.getEnd();
}
if (outputOffset < data.length) {
System.arraycopy(originalData, originalStart, data, outputOffset, data.length - outputOffset);
}
return StringFactory.createShared(data);
}
/**
* Allows to find index of the change that contains given offset (assuming that it is used against <code>'client text'</code>)
* or index of the first change that lays after the given offset.
*
* @param clientOffset target offset against the <code>'client text'</code>
* @return non-negative value that defines index of the stored change that contains given client offset;
* negative value that indicates index of the first change that lays beyond the given offset and
* is calculated by by <code>'-returned_index - 1'</code> formula
*/
private int getChangeIndex(int clientOffset) {
if (myChanges.isEmpty()) {
return -1;
}
int start = 0;
int end = myChanges.size() - 1;
// We inline binary search here because profiling indicates that it becomes bottleneck to use Collections.binarySearch().
while (start <= end) {
int i = (end + start) >>> 1;
ChangeEntry changeEntry = myChanges.get(i);
if (changeEntry.clientStartOffset > clientOffset) {
end = i - 1;
continue;
}
if (changeEntry.clientStartOffset + changeEntry.change.getText().length() < clientOffset) {
start = i + 1;
continue;
}
return i;
}
return -(start + 1);
}
@Override
public String toString() {
return myChanges.toString();
}
/**
* Utility class that contains target {@link TextChangeImpl document change} and auxiliary information associated with it.
*/
private static class ChangeEntry {
/** Target change. */
public TextChangeImpl change;
/**
* Offset of the target change start at the 'client text'.
*/
public int clientStartOffset;
ChangeEntry(TextChangeImpl change, int clientStartOffset) {
this.change = change;
this.clientStartOffset = clientStartOffset;
}
/**
* @return end offset of the current change at the 'client text', i.e. {@link #clientStartOffset} plus change text length
*/
public int getClientEndOffset() {
return clientStartOffset + change.getText().length();
}
@NonNls
@Override
public String toString() {
return "client start offset: " + clientStartOffset + ", change: " + change;
}
}
}