| /* |
| * 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.application.options; |
| |
| import com.intellij.openapi.editor.Document; |
| import com.intellij.openapi.editor.TextChange; |
| import com.intellij.openapi.editor.event.DocumentEvent; |
| import com.intellij.openapi.editor.event.DocumentListener; |
| import com.intellij.openapi.editor.impl.TextChangeImpl; |
| import gnu.trove.TIntArrayList; |
| import org.jetbrains.annotations.NotNull; |
| |
| import java.util.ArrayList; |
| import java.util.List; |
| |
| /** |
| * Aggregates, merges and exposes information about {@link Document document} changes. |
| * <p/> |
| * Intended usage scenario is to collect information about format preview document changes occurred during |
| * tweaking format options and highlight corresponding sections. |
| * <p/> |
| * <b>Note:</b> current algorithm does consider changing particular text by the same text to be a document change, i.e. consider |
| * the document like <code>'abcde'</code>. Suppose it's text from range <code>[2; 4)</code> (<code>'cd'</code>) is replaced by the same |
| * text. It's still to be considered as a change. |
| * <p/> |
| * Not thread-safe. |
| * |
| * @author Denis Zhdanov |
| * @since 10/11/10 5:10 PM |
| */ |
| public class DocumentChangesCollector implements DocumentListener { |
| |
| /** |
| * Holds merged document changes. |
| * <p/> |
| * Is assumed to be always sorted by change start offset in ascending order. |
| */ |
| private final List<TextChangeImpl> myChanges = new ArrayList<TextChangeImpl>(); |
| |
| private boolean myCollectChanges; |
| |
| @Override |
| public void beforeDocumentChange(DocumentEvent event) { |
| } |
| |
| @Override |
| public void documentChanged(DocumentEvent event) { |
| if (!myCollectChanges) { |
| return; |
| } |
| |
| // The general algorithm is as follows: |
| // 1. Drop or cut stored change ranges for removed range if any; |
| // 2. Update offsets of all ranges that lays after the last change range identified by the given document change event; |
| // 3. Merge added range with registered changes if any; |
| // 4. Merge all adjacent ranges if any; |
| StringBuilder oldText = new StringBuilder(event.getOldFragment()); |
| |
| cutChangesIfNecessary(event, oldText); |
| updateChanges(event, oldText); |
| mergeChangesIfNecessary(event); |
| } |
| |
| /** |
| * Cuts or removes stored change ranges for the given interval if any. |
| * |
| * @param event event for just occurred document change |
| * @param oldText our main idea is to merge all changes in order to have aggregated change against original document. So, there is |
| * a possible case that subsequent change affects text inserted by the previous one. We don't want to reflect that |
| * at aggregated change as it didn't appear in initial document. Hence, we have a mutable symbols buffer that holds |
| * symbols from initial document affected by the given change |
| */ |
| private void cutChangesIfNecessary(DocumentEvent event, StringBuilder oldText) { |
| if (myChanges.isEmpty()) { |
| return; |
| } |
| |
| int start = event.getOffset(); |
| int end = event.getOffset() + event.getOldLength(); |
| int diff = event.getNewLength() - event.getOldLength(); |
| |
| int forwardIndex = findIndex(start); |
| int backwardIndexStart = forwardIndex - 1; |
| if (forwardIndex < 0) { |
| backwardIndexStart = myChanges.size() - 1; |
| } |
| else { |
| TIntArrayList indices = new TIntArrayList(); |
| for (; forwardIndex < myChanges.size(); forwardIndex++) { |
| TextChangeImpl change = myChanges.get(forwardIndex); |
| // Stored change is not affected by the current change. |
| if (change.getStart() >= end) { |
| change.advance(diff); |
| } |
| |
| // Stored change region is completely covered by the given change. |
| else if (change.getEnd() <= end) { |
| indices.add(forwardIndex); |
| |
| int deleteStart = start - change.getStart(); |
| deleteStart = Math.max(0, deleteStart); |
| |
| int deleteEnd = change.getEnd() - change.getStart(); |
| deleteEnd = Math.min(oldText.length(), Math.max(0, deleteEnd)); |
| |
| oldText.delete(deleteStart, deleteEnd); |
| oldText.insert(0, change.getText()); |
| } |
| |
| // Stored change region's start is covered by the current change. |
| else { |
| int deleteStart = change.getStart() - start; |
| deleteStart = Math.min(oldText.length(), Math.max(0, deleteStart)); |
| |
| int deleteEnd = oldText.length(); |
| deleteEnd = Math.min(oldText.length(), Math.max(0, deleteEnd)); |
| |
| oldText.delete(deleteStart, deleteEnd); |
| myChanges.set(forwardIndex, new TextChangeImpl(change.getText(), end + diff, change.getEnd() + diff)); |
| } |
| } |
| |
| if (!indices.isEmpty()) { |
| for (int i = indices.size() - 1; i >= 0; i--) { |
| myChanges.remove(indices.get(i)); |
| } |
| } |
| } |
| |
| for (int i = Math.min(backwardIndexStart, myChanges.size() - 1); i >= 0; i--) { |
| TextChangeImpl change = myChanges.get(i); |
| if (change.getEnd() <= start) { |
| break; |
| } |
| |
| CharSequence textToUse = change.getText(); |
| int symbolsToCut = Math.min(change.getEnd(), end) - start; |
| if (textToUse.length() >= symbolsToCut) { |
| oldText.insert(symbolsToCut, textToUse.subSequence(textToUse.length() - symbolsToCut, textToUse.length())); |
| textToUse = textToUse.subSequence(0, textToUse.length() - symbolsToCut); |
| } |
| oldText.delete(0, symbolsToCut); |
| myChanges.set(i, new TextChangeImpl(textToUse, change.getStart(), start)); |
| if (change.getEnd() > end) { |
| int shift = event.getOffset() + event.getNewLength() - end; |
| TextChangeImpl changeTail = new TextChangeImpl("", end + shift, change.getEnd() + shift); |
| if (i >= myChanges.size() - 1) { |
| myChanges.add(changeTail); |
| } |
| else { |
| myChanges.add(i + 1, changeTail); |
| } |
| } |
| } |
| } |
| |
| /** |
| * Updates current object's state on the basis of the given event assuming that there are no stored change ranges that |
| * start after offset denoted by the given event. |
| * |
| * @param event event for just occurred document change |
| * @param oldText our main idea is to merge all changes in order to have aggregated change against original document. So, there is |
| * a possible case that subsequent change affects text inserted by the previous one. We don't want to reflect that |
| * at aggregated change as it didn't appear in initial document. Hence, we have a mutable symbols buffer that holds |
| * symbols from initial document affected by the given change |
| */ |
| private void updateChanges(DocumentEvent event, StringBuilder oldText) { |
| int i = findIndex(event.getOffset()); |
| int endOffset = event.getOffset() + event.getNewLength(); |
| TextChangeImpl change = new TextChangeImpl(oldText, event.getOffset(), endOffset); |
| if (i < 0) { |
| myChanges.add(change); |
| } |
| else { |
| myChanges.add(i, change); |
| } |
| } |
| |
| private void mergeChangesIfNecessary(DocumentEvent event) { |
| // There is a possible case that we had more than one scattered change (e.g. (3; 5) and (8; 10)) and current document change affects |
| // both of them (e.g. remove all symbols from offset (4; 9)). We have two changes then: (3; 4) and (4; 5) and want to merge them |
| // into a single one. |
| if (myChanges.size() < 2) { |
| return; |
| } |
| TextChangeImpl next = myChanges.get(myChanges.size() - 1); |
| for (int i = myChanges.size() - 2; i >= 0; i--) { |
| TextChangeImpl current = myChanges.get(i); |
| if (current.getEnd() < event.getOffset()) { |
| // Assuming that change ranges are always kept at normalized form. |
| break; |
| } |
| if (current.getEnd() == next.getStart()) { |
| myChanges.set(i, next = new TextChangeImpl(current.getText().toString() + next.getText(), current.getStart(), next.getEnd())); |
| myChanges.remove(i + 1); |
| } |
| else { |
| next = current; |
| } |
| } |
| } |
| |
| /** |
| * Returns aggregated and merged document changes sorted by their start offsets in ascending order. |
| * <p/> |
| * <b>Example</b> |
| * <pre> |
| * <ol> |
| * <li>Suppose we have a document with text '0123456';</li> |
| * <li> |
| * Document range <code>[1; 3)</code> is removed (text <code>'12'</code> is removed, current text is |
| * <code>'03456'</code>); |
| * </li> |
| * <li> |
| * Document range <code>[1; 4)</code> (<code>'345'</code>) is replaced to <code>'abc'</code> |
| * (current text is <code>'0abc6'</code>); |
| * </li> |
| * </ol> |
| * </pre> |
| * <p/> |
| * So, initial document is <code>'0123456'</code>; final one is <code>'0abc6'</code>. We expect single aggregated change |
| * to be returned then - <code>[1; 4)</code> with text <code>'12345'</code>, i.e. the range defines offset within the |
| * current document and text shows initial document text at that range. |
| * |
| * @return aggregated and merged document changes sorted by their start offsets in ascending order |
| */ |
| @NotNull |
| public List<? extends TextChange> getChanges() { |
| return myChanges; |
| } |
| |
| /** |
| * Allows to switch <code>'collect document changes'</code> mode. |
| * <p/> |
| * Default value is <code>'false'</code>, i.e. document changes are not collected. |
| * |
| * @param collectChanges new value of <code>'collect document changes'</code> mode |
| */ |
| public void setCollectChanges(boolean collectChanges) { |
| myCollectChanges = collectChanges; |
| } |
| |
| /** |
| * Forces current object to drop all information about document changes if any. |
| */ |
| public void reset() { |
| myChanges.clear(); |
| } |
| |
| /** |
| * Finds index of the {@link #myCollectChanges first stored changed document ranges} which start offset is equal or greater to the |
| * given one. |
| * |
| * @param offset start offset of target document change |
| * @return non-negative value as an indication that there is not stored changed document range which start offset is equal |
| * or greater than the given offset; negative value otherwise |
| */ |
| private int findIndex(int offset) { |
| if (myChanges.isEmpty()) { |
| return -1; |
| } |
| |
| // We assume that document is changed sequentially from start to end, hence, it's worth to perform quick offset comparison with |
| // the last stored change if any |
| TextChangeImpl change = myChanges.get(myChanges.size() - 1); |
| if (offset > change.getStart()) { |
| return -1; |
| } |
| |
| int start = 0; |
| int end = myChanges.size() - 1; |
| int result = -1; |
| |
| // We inline binary search here mainly because TextChange class is immutable and we don't want unnecessary expenses on |
| // new key object construction on every method call. |
| while (start <= end) { |
| result = (end + start) >>> 1; |
| change = myChanges.get(result); |
| if (change.getStart() < offset) { |
| start = ++result; |
| continue; |
| } |
| if (change.getStart() > offset) { |
| end = result - 1; |
| continue; |
| } |
| break; |
| } |
| |
| return result; |
| } |
| } |