blob: 0e448a6c1b2921d5d99d8adad2e7ef722f47e2c9 [file] [log] [blame]
/*******************************************************************************
* Copyright (c) 2000, 2020 IBM Corporation and others.
*
* This program and the accompanying materials
* are made available under the terms of the Eclipse Public License 2.0
* which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* IBM Corporation - initial API and implementation
* Paul Pazderski - Contributions for:
* Bug 547064: use binary search for getPartition
* Bug 548356: fixed user input handling
* Bug 550618: getStyleRanges produced invalid overlapping styles
* Bug 550621: Implementation of IConsoleDocumentPartitionerExtension
* Bug 76936: Support interpretation of \b and \r in console output
* Bug 365770: Race condition in console clearing
* Bug 553282: Support interpretation of \f and \v in console output
*******************************************************************************/
package org.eclipse.ui.internal.console;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Iterator;
import java.util.List;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.eclipse.core.runtime.Assert;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.Status;
import org.eclipse.core.runtime.jobs.Job;
import org.eclipse.jface.text.BadLocationException;
import org.eclipse.jface.text.DocumentEvent;
import org.eclipse.jface.text.IDocument;
import org.eclipse.jface.text.IDocumentPartitionerExtension;
import org.eclipse.jface.text.IRegion;
import org.eclipse.jface.text.ITypedRegion;
import org.eclipse.jface.text.MultiStringMatcher;
import org.eclipse.jface.text.MultiStringMatcher.Match;
import org.eclipse.jface.text.Region;
import org.eclipse.jface.text.TypedRegion;
import org.eclipse.swt.custom.StyleRange;
import org.eclipse.swt.widgets.Display;
import org.eclipse.ui.console.ConsolePlugin;
import org.eclipse.ui.console.IConsoleDocumentPartitioner;
import org.eclipse.ui.console.IConsoleDocumentPartitionerExtension;
import org.eclipse.ui.console.IOConsole;
import org.eclipse.ui.console.IOConsoleInputStream;
import org.eclipse.ui.console.IOConsoleOutputStream;
import org.eclipse.ui.progress.UIJob;
import org.eclipse.ui.progress.WorkbenchJob;
/**
* Partitions an IOConsole's document
*
* @since 3.1
*/
public class IOConsolePartitioner
implements IConsoleDocumentPartitioner, IConsoleDocumentPartitionerExtension, IDocumentPartitionerExtension {
/**
* Enumeration used to distinct sources of document updates. (especially to
* distinct updates triggered by this partitioner from other document changes)
*/
private enum DocUpdateType {
/**
* Default if reason for document change is not known. Document change is
* interpreted as user input.
*/
INPUT,
/**
* Document update was triggered from this partitioner by appending content
* received from output streams.
*/
OUTPUT,
/** Document update was triggered from this partitioner's {@link TrimJob}. */
TRIM,
}
/**
* If true validate partitioning after changes and do other additional
* assertions. Useful for developing/debugging.
*/
private static final boolean ASSERT = false;
/**
* Comparator to sort or search {@link IRegion}s by {@link IRegion#getOffset()}.
*/
private static final Comparator<IRegion> CMP_REGION_BY_OFFSET = Comparator.comparing(IRegion::getOffset);
/**
* Pattern used to find supported ASCII control characters <b>except</b>
* carriage return.
*/
private static final String CONTROL_CHARACTERS_PATTERN_STR = "(?:\b+|\u0000+|\u000b+|\f+)"; //$NON-NLS-1$
/**
* Pattern used to find supported ASCII control characters <b>including</b>
* carriage return.
*/
private static final String CONTROL_CHARACTERS_WITH_CR_PATTERN_STR = "(?:\b+|\u0000+|\u000b+|\f+|\r+(?!\n))"; //$NON-NLS-1$
/** The connected {@link IDocument} this partitioner manages. */
private IDocument document;
/**
* List of all partitions. Must always be sorted ascending by
* {@link IRegion#getOffset()} and not contain <code>null</code> or 0-length
* elements. (see also {@link #checkPartitions()})
*/
private final ArrayList<IOConsolePartition> partitions = new ArrayList<>();
/** Blocks of data that have not yet been appended to the document. */
private final ArrayList<PendingPartition> pendingPartitions = new ArrayList<>();
/** Total length of pending partitions content. */
private int pendingSize;
/** Job that appends pending partitions to the document. */
private final QueueProcessingJob queueJob = new QueueProcessingJob();
/** Job that trims console content if it exceeds {@link #highWaterMark}. */
private final TrimJob trimJob = new TrimJob();
/**
* Reason for document update. Set before changing document inside this
* partitioner to prevent that change is interpreted as user input.
* <p>
* Automatically reset to {@link DocUpdateType#INPUT} after every document
* change.
* </p>
*/
private DocUpdateType updateType = DocUpdateType.INPUT;
private IRegion changedRegion;
/**
* A list of partitions containing input from the console, that have not been
* appended to the input stream yet. No guarantees on element order.
*/
private ArrayList<IOConsolePartition> inputPartitions;
/**
* A matcher to search for legal line delimiters in new input. Never
* <code>null</code> but match nothing if no document connected.
*/
private MultiStringMatcher legalLineDelimiterMatcher;
/**
* The high mark for console content trimming. If console content exceeds this
* length trimming is scheduled. Trimming is disabled if value is negative.
*/
private int highWaterMark = -1;
/**
* The low mark for console content trimming. If trim is performed approximate
* this many characters are remain in console.
*/
private int lowWaterMark = -1;
/** The partitioned {@link IOConsole}. */
private IOConsole console;
/** Set after console signaled that all streams are closed. */
private volatile boolean streamsClosed;
/**
* Active pattern to search for supported control characters. If
* <code>null</code> control characters are treated as any other characters.
*/
private Pattern controlCharacterPattern = null;
/**
* Whether <code>\r</code> is interpreted as control characters
* (<code>true</code>) or not in console output. If <code>false</code> they are
* probably handled as newline.
*/
private boolean carriageReturnAsControlCharacter = true;
/**
* Offset where next output is written to console.
*/
private int outputOffset = 0;
/**
* Create new partitioner for an {@link IOConsole}.
* <p>
* The partitioner must be explicit {@link #connect(IDocument) connected} with
* the consoles {@link IDocument}.
* </p>
*
* @param console the partitioned console. Not <code>null</code>.
*/
public IOConsolePartitioner(IOConsole console) {
this.console = Objects.requireNonNull(console);
queueJob.setRule(console.getSchedulingRule());
trimJob.setRule(console.getSchedulingRule());
}
/**
* Get partitioned document or <code>null</code> if none connected.
*
* @return partitioned document
*/
public IDocument getDocument() {
return document;
}
@Override
public void connect(IDocument doc) {
if (doc == document) {
return;
}
disconnect();
if (doc != null) {
synchronized (partitions) {
inputPartitions = new ArrayList<>();
document = doc;
legalLineDelimiterMatcher = MultiStringMatcher.create(document.getLegalLineDelimiters());
}
}
}
@Override
public void disconnect() {
synchronized (pendingPartitions) {
pendingPartitions.clear();
pendingSize = 0;
pendingPartitions.notifyAll();
}
synchronized (partitions) {
trimJob.cancel();
queueJob.cancel();
legalLineDelimiterMatcher = null;
document = null;
inputPartitions = null;
partitions.clear();
}
}
/**
* Get high water mark.
*
* @return the trim if exceeded mark
* @see IOConsole#getHighWaterMark()
*/
public int getHighWaterMark() {
return highWaterMark;
}
/**
* Get low water mark.
*
* @return the trim to this length mark
* @see IOConsole#getLowWaterMark()
*/
public int getLowWaterMark() {
return lowWaterMark;
}
/**
* Set low and high water marks.
*
* @param low the trim to this length mark
* @param high the trim if exceeded mark
* @see IOConsole#setWaterMarks(int, int)
*/
public void setWaterMarks(int low, int high) {
lowWaterMark = low;
highWaterMark = high;
ConsolePlugin.getStandardDisplay().asyncExec(this::checkBufferSize);
}
/**
* Notification from the console that all of its streams have been closed.
*/
public void streamsClosed() {
if (streamsClosed) {
String msg = "Streams are already closed.";//$NON-NLS-1$
log(IStatus.ERROR, msg, new IllegalStateException(msg));
return;
}
streamsClosed = true;
checkFinished();
}
/**
* Check if partitioner is finished and does not expect any new data appended to
* document.
*/
private void checkFinished() {
if (streamsClosed) {
// do not expect new data since all streams are closed
// check if pending data is queued
final boolean morePending;
synchronized (pendingPartitions) {
morePending = !pendingPartitions.isEmpty();
}
if (morePending) {
queueJob.schedule();
} else {
console.partitionerFinished();
}
}
}
@Override
public void documentAboutToBeChanged(DocumentEvent event) {
}
@Override
public boolean documentChanged(DocumentEvent event) {
return documentChanged2(event) != null;
}
@Override
public String[] getLegalContentTypes() {
return new String[] { IOConsolePartition.OUTPUT_PARTITION_TYPE, IOConsolePartition.INPUT_PARTITION_TYPE };
}
@Override
public String getContentType(int offset) {
return getPartition(offset).getType();
}
@Override
public ITypedRegion[] computePartitioning(int offset, int length) {
return computeIOPartitioning(offset, length);
}
/**
* Same as {@link #computePartitioning(int, int)} but with more specific return
* type.
*
* @param offset the offset of the range of interest
* @param length the length of the range of interest
* @return the partitioning of the requested range (never <code>null</code>)
*/
private IOConsolePartition[] computeIOPartitioning(int offset, int length) {
return computePartitioning(offset, length, true, true);
}
/**
* Get partitioning for a given range with possibility to filter partitions by
* their read-only property.
*
* @param offset the offset of the range of interest
* @param length the length of the range of interest
* @param includeWritable if false writable partitions are skipped
* @param includeReadOnly if false read-only partitions are skipped
* @return the partitioning of the requested range (never <code>null</code>)
*/
private IOConsolePartition[] computePartitioning(int offset, int length, boolean includeWritable,
boolean includeReadOnly) {
final List<IOConsolePartition> result = new ArrayList<>();
synchronized (partitions) {
int index = findPartitionCandidate(offset);
if (index < 0) { // requested range starts before any known partition offset
index = 0; // so we start collecting at first known partition
}
final int end = offset + length;
for (; index < partitions.size(); index++) {
final IOConsolePartition partition = partitions.get(index);
if (partition.getOffset() >= end) {
break;
}
if ((includeWritable && !partition.isReadOnly()) || (includeReadOnly && partition.isReadOnly())) {
result.add(partition);
}
}
}
return result.toArray(new IOConsolePartition[0]);
}
@Override
public ITypedRegion getPartition(int offset) {
final ITypedRegion partition = getIOPartition(offset);
return partition != null ? partition : new TypedRegion(offset, 0, IOConsolePartition.INPUT_PARTITION_TYPE);
}
/**
* Like {@link #getPartition(int)} but returns <code>null</code> for
* unpartitioned or invalid offsets.
*
* @param offset the offset for which to determine the partition
* @return the partition containing this offset or <code>null</code> if offset
* is not partitioned
*/
private IOConsolePartition getIOPartition(int offset) {
synchronized (partitions) {
final int index = findPartitionCandidate(offset);
if (index >= 0) {
final IOConsolePartition partition = partitions.get(index);
if (partition.getOffset() + partition.getLength() > offset) {
return partition;
}
}
return null;
}
}
/**
* Search {@link #partitions} for the partition which is most likely containing
* the requested offset.
* <p>
* This (index + 1) can be used to insert a new partition with this offset. The
* resulting {@link #partitions} list is guaranteed to still be sorted. (as long
* as you do proper synchronization and consider concurrency problems)
* </p>
*
* @param offset the offset for which to determine the partition candidate
* @return index of partition element with partition offset closest to requested
* offset or <code>-1</code> if requested offset is lower than offset of
* any known partition
*/
private int findPartitionCandidate(int offset) {
final Region target = new Region(offset, 0);
final int index = Collections.binarySearch(partitions, target, CMP_REGION_BY_OFFSET);
if (index >= 0) {
// found partition whose offset equals the requested offset
return index;
}
// no exact offset match. Adjust index to point at partition which is closest to
// requested offset but whose offset is still lower than requested offset.
// Results in -1 if all known offsets are greater.
return (-index) - 2;
}
/**
* Enforces the buffer size.
* <p>
* When the document length exceeds the high water mark, the beginning of the
* document is trimmed until the document length is approximately the low water
* mark.
* </p>
*/
private void checkBufferSize() {
if (document != null && highWaterMark > 0) {
int length = document.getLength();
if (length > highWaterMark) {
if (trimJob.getState() == Job.NONE) { // if the job isn't already running
trimJob.setTrimLineOffset(length - lowWaterMark);
trimJob.schedule();
}
}
}
}
/**
* Clears the console content.
*/
public void clearBuffer() {
synchronized (pendingPartitions) {
pendingPartitions.clear();
pendingSize = 0;
}
synchronized (partitions) {
if (document != null) {
trimJob.setTrimOffset(document.getLength());
trimJob.schedule();
}
}
}
@Override
public IRegion documentChanged2(DocumentEvent event) {
try {
if (document != event.getDocument()) {
log(IStatus.WARNING, "IOConsolePartitioner is connected to wrong document."); //$NON-NLS-1$
return null;
}
if (document.getLength() == 0) { // document cleared
synchronized (partitions) {
partitions.clear();
inputPartitions.clear();
outputOffset = 0;
}
return new Region(0, 0);
}
synchronized (partitions) {
switch (updateType) {
case INPUT:
if (event.getOffset() <= outputOffset) { // move output offset if necessary
outputOffset -= Math.min(event.getLength(), outputOffset - event.getOffset());
if (event.getText() != null) {
outputOffset += event.getText().length();
}
}
return applyUserInput(event);
// update and trim jobs are triggered by this partitioner and all partitioning
// changes are applied separately
case OUTPUT:
return changedRegion;
case TRIM:
return null; // trim does not change partition types
default:
log(IStatus.ERROR, "Invalid enum value " + updateType); //$NON-NLS-1$
return null;
}
}
} finally {
// always reset type since all change events not triggered by this partitioner
// are interpreted as user input
updateType = DocUpdateType.INPUT;
}
}
/**
* Update partitioning due to document change. All document change events not
* triggered by this partitioner are considered user input and therefore
* partitioned as input.
* <p>
* This method does not care if the document event removed or replaced parts of
* read-only partitions. It assumes manipulating read-only partitions is valid
* or is blocked before this method is used.
* </p>
*
* @param event the event describing the document change
* @return the region of the document in which the partition type changed or
* <code>null</code>
*/
// Required for a false 'resource not closed' warning on inputStream.
// This input stream must not be closed by this method.
@SuppressWarnings("resource")
private IRegion applyUserInput(DocumentEvent event) {
final int eventTextLength = event.getText() != null ? event.getText().length() : 0;
final int offset = event.getOffset();
final int amountDeleted = event.getLength();
final IOConsoleInputStream inputStream = console.getInputStream(); // do not close this stream
if (amountDeleted == 0 && eventTextLength == 0) {
// event did not changed document
return null;
}
final int eventPartitionIndex = findPartitionCandidate(offset);
int lastPartitionWithValidOffset = eventPartitionIndex;
if (amountDeleted > 0 && eventPartitionIndex >= 0) {
// adjust length of all partitions affected by replace/remove event
int toDelete = amountDeleted;
for (int i = eventPartitionIndex; i < partitions.size() && toDelete > 0; i++) {
final IOConsolePartition partition = partitions.get(i);
final int removeLength = Math.min(partition.getLength(), toDelete);
partition.setLength(partition.getLength() - removeLength);
toDelete -= removeLength;
}
if (ASSERT) {
Assert.isTrue(toDelete == 0, "Tried to delete outside partitioned range."); //$NON-NLS-1$
}
lastPartitionWithValidOffset--; // update one more since first affected partition may be empty now
}
if (eventTextLength > 0) {
// find best partition for event text
int inputPartitionIndex = eventPartitionIndex;
IOConsolePartition inputPartition = getPartitionByIndex(inputPartitionIndex);
if (inputPartition != null && inputPartition.isReadOnly() && offset == inputPartition.getOffset()) {
// if we could not reuse partition at event offset we may append the partition
// right before our event offset (e.g. if input is at end of document)
inputPartitionIndex--;
lastPartitionWithValidOffset--;
inputPartition = getPartitionByIndex(inputPartitionIndex);
}
// process event text in parts split on line delimiters
int textOffset = 0;
while (textOffset < eventTextLength) {
final Match nextNewline = legalLineDelimiterMatcher.indexOf(event.getText(), textOffset);
final int newTextOffset = nextNewline != null ? nextNewline.getOffset() + nextNewline.getText().length()
: eventTextLength;
final int inputLength = newTextOffset - textOffset;
if (inputPartition == null || inputPartition.isReadOnly()) {
final int inputOffset = offset + textOffset;
if (inputPartition != null
&& inputOffset < inputPartition.getOffset() + inputPartition.getLength()) {
// input is inside an existing read-only partition
splitPartition(inputOffset);
}
inputPartition = new IOConsolePartition(inputOffset, inputStream);
inputPartitionIndex++;
partitions.add(inputPartitionIndex, inputPartition);
inputPartitions.add(inputPartition);
lastPartitionWithValidOffset++; // new input partitions get build with correct offsets
}
inputPartition.setLength(inputPartition.getLength() + inputLength);
if (nextNewline != null) {
inputPartitions.sort(CMP_REGION_BY_OFFSET);
final StringBuilder inputLine = new StringBuilder();
for (IOConsolePartition p : inputPartitions) {
try {
final String fragment = document.get(p.getOffset(), p.getLength());
inputLine.append(fragment);
} catch (BadLocationException e) {
log(e);
}
p.setReadOnly();
}
inputPartitions.clear();
if (ASSERT) {
Assert.isTrue(inputLine.length() > 0);
}
if (inputStream != null) {
inputStream.appendData(inputLine.toString());
}
}
Assert.isTrue(newTextOffset > textOffset); // can prevent infinity loop
textOffset = newTextOffset;
}
}
// repair partition offsets
int newOffset = 0;
if (lastPartitionWithValidOffset >= 0) {
// reduce number of partition to update by skipping still valid entries
final IOConsolePartition partition = partitions.get(lastPartitionWithValidOffset);
newOffset = partition.getOffset() + partition.getLength();
}
final Iterator<IOConsolePartition> it = partitions.listIterator(lastPartitionWithValidOffset + 1);
while (it.hasNext()) {
final IOConsolePartition partition = it.next();
if (partition.getLength() <= 0) {
if (ASSERT) {
Assert.isTrue(partition.getLength() == 0);
}
it.remove();
if (isInputPartition(partition)) {
final boolean removed = inputPartitions.remove(partition);
if (ASSERT) {
Assert.isTrue(removed);
}
}
} else {
partition.setOffset(newOffset);
newOffset += partition.getLength();
}
}
if (ASSERT) {
checkPartitions();
}
return new Region(0, document.getLength());
}
/**
* Split an existing partition at offset. The offset must not be the first or
* last offset of the existing partition because this leads to empty partitions
* not bearable by this partitioner.
* <p>
* New partition is added to {@link #partitions} (always) and
* {@link #inputPartitions} (if applicable).
* </p>
*
* @param offset the offset where the existing partition will end after split
* and a new partition will start
* @return the newly created partition (i.e. the right side of the split)
*/
@SuppressWarnings("resource") // suppress wrong 'not closed' warnings
private IOConsolePartition splitPartition(int offset) {
final int partitionIndex = findPartitionCandidate(offset);
final IOConsolePartition existingPartition = partitions.get(partitionIndex);
final IOConsolePartition newPartition;
if (isInputPartition(existingPartition)) {
newPartition = new IOConsolePartition(offset, existingPartition.getInputStream());
if (existingPartition.isReadOnly()) {
newPartition.setReadOnly();
}
if (inputPartitions.contains(existingPartition)) {
inputPartitions.add(newPartition);
}
} else {
newPartition = new IOConsolePartition(offset, existingPartition.getOutputStream());
}
newPartition.setLength((existingPartition.getOffset() + existingPartition.getLength()) - offset);
existingPartition.setLength(offset - existingPartition.getOffset());
partitions.add(partitionIndex + 1, newPartition);
return newPartition;
}
/**
* A stream has been appended, add to pendingPartions list and schedule
* updateJob. updateJob is scheduled with a slight delay, this allows the
* console to run the job less frequently and update the document with a greater
* amount of data each time the job is run.
*
* @param stream The stream that was written to.
* @param s The string that should be appended to the document.
* @throws IOException if partitioner is not connected to a document
*/
public void streamAppended(IOConsoleOutputStream stream, String s) throws IOException {
if (document == null) {
throw new IOException("Document is closed"); //$NON-NLS-1$
}
if (s == null) {
return;
}
synchronized (pendingPartitions) {
final PendingPartition lastPending = pendingPartitions.size() > 0
? pendingPartitions.get(pendingPartitions.size() - 1)
: null;
if (lastPending != null && lastPending.stream == stream) {
lastPending.append(s);
} else {
pendingPartitions.add(new PendingPartition(stream, s));
}
if (pendingSize > 1000) {
queueJob.schedule();
} else {
queueJob.schedule(50);
}
if (pendingSize > 160000) {
if (Display.getCurrent() == null) {
try {
// Block thread to give UI time to process pending output.
// Do not wait forever. Current thread and UI thread might share locks. An
// example is bug 421303 where current thread and UI thread both write to
// console and therefore both need the write lock for IOConsoleOutputStream.
pendingPartitions.wait(1000);
} catch (InterruptedException e) {
}
} else {
// If we are in UI thread we cannot lock it, so process queued output.
queueJob.processPendingPartitions();
}
}
}
}
/**
* Holds data until updateJob can be run and the document can be updated.
*/
private class PendingPartition {
StringBuilder text = new StringBuilder(8192);
IOConsoleOutputStream stream;
PendingPartition(IOConsoleOutputStream stream, String text) {
this.stream = stream;
append(text);
}
void append(String moreText) {
text.append(moreText);
pendingSize += moreText.length();
}
}
/**
* Updates the document and partitioning structure. Will append everything
* received from output streams that is available before finishing.
*/
private class QueueProcessingJob extends UIJob {
/** The partition which contains the current output offset. */
private IOConsolePartition atOutputPartition = null;
/** The index of atOutputPartition in the partitions list. */
private int atOutputPartitionIndex = -1;
/** The pending number of characters to replace in document. */
private int replaceLength;
/** The pending content to be inserted in document. */
private StringBuilder content;
/** The offset in document where to apply the next replace. */
private int nextWriteOffset;
QueueProcessingJob() {
super("IOConsole Updater"); //$NON-NLS-1$
setSystem(true);
setPriority(Job.INTERACTIVE);
}
@Override
public IStatus runInUIThread(IProgressMonitor monitor) {
processPendingPartitions();
if (ASSERT) {
checkPartitions();
}
return Status.OK_STATUS;
}
/*
* Job will process as much as it can each time it's run, but it gets scheduled
* everytime a PendingPartition is added to the list, meaning that this job
* could get scheduled unnecessarily in cases of heavy output. Note however,
* that schedule() will only reschedule a running/scheduled Job once even if
* it's called many times.
*/
@Override
public boolean shouldRun() {
synchronized (pendingPartitions) {
final boolean shouldRun = pendingPartitions.size() > 0;
return shouldRun;
}
}
/**
* Process {@link #pendingPartitions}, append their content to document and
* update partitioning.
*/
private void processPendingPartitions() {
final List<PendingPartition> pendingCopy;
final int size;
synchronized (pendingPartitions) {
pendingCopy = new ArrayList<>(pendingPartitions);
size = pendingSize;
pendingPartitions.clear();
pendingSize = 0;
pendingPartitions.notifyAll();
}
synchronized (partitions) {
if (document != null) {
applyStreamOutput(pendingCopy, size);
}
}
checkFinished();
checkBufferSize();
}
/**
* Apply content collected in pending partitions to document and update
* partitioning structure.
* <p>
* This method is also responsible to interpret control characters if enabled
* (see {@link #isHandleControlCharacters()}).
* </p>
*
* @param pendingCopy the pending partitions to process
* @param sizeHint a hint for expected content length to initialize buffer
* size. Does not have to be exact as long as it is not
* negative.
*/
private void applyStreamOutput(List<PendingPartition> pendingCopy, int sizeHint) {
// local reference to get consistent parsing without blocking pattern changes
final Pattern controlPattern = controlCharacterPattern;
// Variables to collect required data to reduce number of document updates. The
// partitioning must be updated in smaller iterations as the actual document
// content. E.g. pending partitions are distinct on source output stream
// resulting in multiple partitions but if all the content is appended to the
// document there is only one update required to add the actual content.
nextWriteOffset = outputOffset;
content = new StringBuilder(sizeHint);
replaceLength = 0;
atOutputPartition = null;
atOutputPartitionIndex = -1;
for (PendingPartition pending : pendingCopy) {
// create matcher to find control characters in pending content (if enabled)
final Matcher controlCharacterMatcher = controlPattern != null ? controlPattern.matcher(pending.text)
: null;
for (int textOffset = 0; textOffset < pending.text.length();) {
// Process pending content in chunks.
// Processing is primary split on control characters since there interpretation
// is easier if all content changes before are already applied.
// Additional processing splits may result while overwriting existing output and
// overwrite overlaps partitions.
final boolean foundControlCharacter;
final int partEnd;
if (controlCharacterMatcher != null && controlCharacterMatcher.find()) {
if (ASSERT) {
// check used pattern. Assert it matches only sequences of same characters.
final String match = controlCharacterMatcher.group();
Assert.isTrue(match.length() > 0);
final char matchedChar = match.charAt(0);
for (char c : match.toCharArray()) {
Assert.isTrue(c == matchedChar);
}
}
partEnd = controlCharacterMatcher.start();
foundControlCharacter = true;
} else {
partEnd = pending.text.length();
foundControlCharacter = false;
}
partititonContent(pending.stream, pending.text, textOffset, partEnd);
textOffset = partEnd;
// finished processing of regular content before control characters
// now interpret control characters if any
if (controlCharacterMatcher != null && foundControlCharacter) {
// at first update console document since it is easier to interpret control
// characters on an up-to-date document and partitioning
applyOutputToDocument(content.toString(), nextWriteOffset, replaceLength);
content.setLength(0);
replaceLength = 0;
nextWriteOffset = outputOffset;
final String controlCharacterMatch = controlCharacterMatcher.group();
final char controlCharacter = controlCharacterMatch.charAt(0);
final int outputLineStartOffset = findOutputLineStartOffset(outputOffset);
switch (controlCharacter) {
case '\b':
// move virtual output cursor one step back for each \b
// but stop at current line start and skip any input partitions
int backStepCount = controlCharacterMatch.length();
if (partitions.size() == 0) {
outputOffset = 0;
break;
}
if (atOutputPartition == null) {
atOutputPartitionIndex = partitions.size() - 1;
atOutputPartition = getPartitionByIndex(atOutputPartitionIndex);
}
while (backStepCount > 0 && outputOffset > outputLineStartOffset) {
if (atOutputPartition != null && isInputPartition(atOutputPartition)) {
do {
outputOffset = atOutputPartition.getOffset() - 1;
atOutputPartitionIndex--;
atOutputPartition = getPartitionByIndex(atOutputPartitionIndex);
} while (atOutputPartition != null && isInputPartition(atOutputPartition));
backStepCount--;
}
if (atOutputPartition == null) {
outputOffset = 0;
break;
}
final int backSteps = Math.min(outputOffset - atOutputPartition.getOffset(),
backStepCount);
outputOffset -= backSteps;
backStepCount -= backSteps;
atOutputPartitionIndex--;
atOutputPartition = getPartitionByIndex(atOutputPartitionIndex);
}
outputOffset = Math.max(outputOffset, outputLineStartOffset);
nextWriteOffset = outputOffset;
break;
case '\r':
// move virtual output cursor to start of output line
outputOffset = outputLineStartOffset;
atOutputPartitionIndex = -1;
atOutputPartition = null;
nextWriteOffset = outputOffset;
break;
case '\f':
case '\u000b': // \v
// Vertical tab does not override existing content. It will introduce a newline
// (at the end of current line even if output offset is inside the line) and
// indent the new line dependent on current output offset.
int indention = outputOffset - outputLineStartOffset;
final int vtabCount = controlCharacterMatch.length();
final StringBuilder vtab = new StringBuilder(indention + vtabCount);
for (int i = 0; i < vtabCount; i++) {
vtab.append(System.lineSeparator());
}
for (int i = 0; i < indention; i++) {
vtab.append(' ');
}
outputOffset = document.getLength();
nextWriteOffset = outputOffset;
partititonContent(pending.stream, vtab, 0, vtab.length());
break;
case 0:
// Do nothing for null bytes. The use of this is that a null byte which reach
// the IOConsoleViewer will truncate the line on some platforms and will disturb
// copying text on most platforms.
// This case should simply filter out any null bytes.
break;
default:
// should never happen as long as the used regex pattern is valid
log(IStatus.ERROR, "No implementation to handle control character 0x" //$NON-NLS-1$
+ Integer.toHexString(controlCharacter));
break;
}
textOffset = controlCharacterMatcher.end();
}
}
}
applyOutputToDocument(content.toString(), nextWriteOffset, replaceLength);
content = null;
}
/**
* If {@link IOConsolePartitioner#outputOffset} is at end of current content it
* will simply append the new partition or extend the last existing if
* applicable.
* <p>
* If output offset is within existing content the method will overwrite
* existing content and handle all required replacements and adjustments of
* existing partitions.
* </p>
*
* @param stream the stream the to be partitioned content belongs to aka the
* stream which appended the content
* @param text the text to partition. Depending on given offsets only a
* part of text is partitioned.
* @param offset the start offset (inclusive) within text to partition
* @param endOffset the end offset (exclusive) within text to partition
*/
private void partititonContent(IOConsoleOutputStream stream, CharSequence text, int offset, int endOffset) {
int textOffset = offset;
while (textOffset < endOffset) {
// Process content part. This part never contains control characters.
// Processing may require multiple iterations if we overwrite existing content
// which consists of distinct partitions.
if (outputOffset >= document.getLength()) {
// content is appended to document end (the easy case)
if (atOutputPartition == null) {
// get the last existing partition to try to expand it
atOutputPartitionIndex = partitions.size() - 1;
atOutputPartition = getPartitionByIndex(atOutputPartitionIndex);
if (ASSERT) {
Assert.isTrue(atOutputPartitionIndex == findPartitionCandidate(outputOffset - 1));
}
}
if (atOutputPartition == null || !atOutputPartition.belongsTo(stream)) {
// no partitions yet or last partition is incompatible to reuse -> add new one
atOutputPartition = new IOConsolePartition(outputOffset, stream);
partitions.add(atOutputPartition);
atOutputPartitionIndex = partitions.size() - 1;
}
final int appendedLength = endOffset - textOffset;
content.append(text, textOffset, endOffset);
atOutputPartition.setLength(atOutputPartition.getLength() + appendedLength);
outputOffset += appendedLength;
textOffset = endOffset;
} else {
// content overwrites existing console content (the tricky case)
if (atOutputPartition == null) {
// find partition where output will overwrite or create one if unpartitioned
atOutputPartitionIndex = findPartitionCandidate(outputOffset);
atOutputPartition = getPartitionByIndex(atOutputPartitionIndex);
if (atOutputPartition == null) {
atOutputPartition = new IOConsolePartition(outputOffset, stream);
atOutputPartitionIndex++;
partitions.add(atOutputPartitionIndex, atOutputPartition);
}
}
// we do not overwrite input partitions at the moment so they need to be skipped
if (isInputPartition(atOutputPartition)) {
outputOffset = atOutputPartition.getOffset() + atOutputPartition.getLength();
atOutputPartitionIndex++;
atOutputPartition = getPartitionByIndex(atOutputPartitionIndex);
// apply document changes collected until now
applyOutputToDocument(content.toString(), nextWriteOffset, replaceLength);
content.setLength(0);
replaceLength = 0;
nextWriteOffset = outputOffset;
continue; // to check if next selected partition is also input or appending now
}
// limit chunks to overwrite only one existing partition at a time
final int chunkLength = Math.min(endOffset - textOffset,
atOutputPartition.getLength() - (outputOffset - atOutputPartition.getOffset()));
Assert.isTrue(chunkLength > 0); // do not remove since it can prevent an infinity loop
if (!atOutputPartition.belongsTo(stream)) {
// new output is from other stream then overwritten output
// Note: this implementation ignores the possibility to reuse the partition
// where the overwrite chunk ends and expand it towards replace begin since this
// makes things code much more complex. In some cases this may leads to
// consecutive partitions which could be merged to one partition. Merging is not
// implemented at the moment.
// in this part outputPartition is used to partition the new content
// and atOutputPartition points to the partition whose content is overwritten
// i.e. the new partition grows and the old one must shrink
IOConsolePartition outputPartition = null;
if (atOutputPartition.getOffset() == outputOffset) {
// try to expand the partition before our output offset
outputPartition = getPartitionByIndex(atOutputPartitionIndex - 1);
} else {
// overwrite starts inside existing incompatible partition
atOutputPartition = splitPartition(outputOffset);
atOutputPartitionIndex++;
}
if (outputPartition == null || !outputPartition.belongsTo(stream)) {
outputPartition = new IOConsolePartition(outputOffset, stream);
partitions.add(atOutputPartitionIndex, outputPartition);
atOutputPartitionIndex++;
}
// update partitioning of the overwritten chunk
outputPartition.setLength(outputPartition.getLength() + chunkLength);
atOutputPartition.setOffset(atOutputPartition.getOffset() + chunkLength);
atOutputPartition.setLength(atOutputPartition.getLength() - chunkLength);
if (atOutputPartition.getLength() == 0) {
// overwritten partition is now empty and must be be removed
partitions.remove(atOutputPartitionIndex);
atOutputPartition = getPartitionByIndex(atOutputPartitionIndex);
}
}
content.append(text, textOffset, textOffset + chunkLength);
replaceLength += chunkLength;
textOffset += chunkLength;
outputOffset += chunkLength;
if (atOutputPartition != null
&& outputOffset == atOutputPartition.getOffset() + atOutputPartition.getLength()) {
atOutputPartitionIndex++;
atOutputPartition = getPartitionByIndex(atOutputPartitionIndex);
}
}
}
}
/**
* Find offset of line start from given output offset. This method ignores line
* breaks partitioned as input. I.e. it looks at the document as if it only
* consist of the output parts.
*
* @param outOffset offset where output should be written
* @return the start offset of line where output should be written
*/
private int findOutputLineStartOffset(int outOffset) {
int outputLineStartOffset = 0;
try {
for (int lineIndex = document.getLineOfOffset(outOffset); lineIndex >= 0; lineIndex--) {
outputLineStartOffset = document.getLineOffset(lineIndex);
final IOConsolePartition lineBreakPartition = getIOPartition(outputLineStartOffset - 1);
if (lineBreakPartition == null || !isInputPartition(lineBreakPartition)) {
break;
}
}
} catch (BadLocationException e) {
log(e);
outputLineStartOffset = 0;
}
if (ASSERT) {
Assert.isTrue(outputLineStartOffset <= outOffset);
}
return outputLineStartOffset;
}
/**
* Apply content from output streams to document. It expects the partitioning
* has or will update partitioning to reflect the change since it prevents this
* partitioner's {@link #documentChanged2(DocumentEvent)} method from changing
* partitioning.
*
* @param text collected content from output streams; not <code>null</code>
* @param offset offset in document where content is inserted
* @param length length of overwritten old output
*/
private void applyOutputToDocument(String text, int offset, int length) {
if (text.length() > 0 || length > 0) {
if (ASSERT) {
Assert.isTrue(length <= text.length());
}
try {
updateType = DocUpdateType.OUTPUT;
document.replace(offset, length, text);
} catch (BadLocationException e) {
log(e);
}
}
}
}
/**
* Job to trim the console document, runs in the UI thread.
*/
private class TrimJob extends WorkbenchJob {
/** Trims output up to given offset. */
private int truncateOffset;
/**
* If <code>true</code> trim only to start of line containing the
* {@link #truncateOffset}.
*/
private boolean truncateToOffsetLineStart;
/**
* Creates a new job to trim the buffer.
*/
TrimJob() {
super("Trim Job"); //$NON-NLS-1$
setSystem(true);
}
/**
* Sets the trim offset.
*
* @param offset trims console content up to this offset
*/
public void setTrimOffset(int offset) {
truncateOffset = offset;
truncateToOffsetLineStart = false;
}
/**
* Sets the trim offset.
*
* @param offset trims output up to the line containing this offset
*/
public void setTrimLineOffset(int offset) {
truncateOffset = offset;
truncateToOffsetLineStart = true;
}
@Override
public IStatus runInUIThread(IProgressMonitor monitor) {
synchronized (partitions) {
if (document == null) {
return Status.OK_STATUS;
}
try {
int length = document.getLength();
int cutOffset = truncateOffset;
if (truncateToOffsetLineStart) {
int cutoffLine = document.getLineOfOffset(truncateOffset);
cutOffset = document.getLineOffset(cutoffLine);
}
if (cutOffset >= length) {
updateType = DocUpdateType.TRIM;
document.set(""); //$NON-NLS-1$
} else {
// set the new length of the first partition
IOConsolePartition partition = getIOPartition(cutOffset);
partition.setLength(partition.getOffset() + partition.getLength() - cutOffset);
updateType = DocUpdateType.TRIM;
document.replace(0, cutOffset, ""); //$NON-NLS-1$
// remove partitions and reset Partition offsets
int index = partitions.indexOf(partition);
for (int i = 0; i < index; i++) {
partitions.remove(0);
}
int offset = 0;
for (IOConsolePartition p : partitions) {
p.setOffset(offset);
offset += p.getLength();
}
// fix output offset
int removedLength = cutOffset;
outputOffset = Math.max(outputOffset - removedLength, 0);
}
if (ASSERT) {
checkPartitions();
}
} catch (BadLocationException e) {
log(e);
}
}
return Status.OK_STATUS;
}
}
@Override
public boolean isReadOnly(int offset) {
final IOConsolePartition partition = getIOPartition(offset);
return partition != null ? partition.isReadOnly() : true;
}
@Override
public StyleRange[] getStyleRanges(int offset, int length) {
final IOConsolePartition[] computedPartitions = computeIOPartitioning(offset, length);
final StyleRange[] styles = new StyleRange[computedPartitions.length];
for (int i = 0; i < computedPartitions.length; i++) {
int rangeStart = computedPartitions[i].getOffset();
int rangeLength = computedPartitions[i].getLength();
// snap partitions to requested range
final int underflow = offset - rangeStart;
if (underflow > 0) {
rangeStart += underflow;
rangeLength -= underflow;
}
final int overflow = (rangeStart + rangeLength) - (offset + length);
if (overflow > 0) {
rangeLength -= overflow;
}
styles[i] = computedPartitions[i].getStyleRange(rangeStart, rangeLength);
}
return styles;
}
@Override
public ITypedRegion[] computeReadOnlyPartitions() {
if (document == null) {
return new IOConsolePartition[0];
}
return computeReadOnlyPartitions(0, document.getLength());
}
@Override
public ITypedRegion[] computeReadOnlyPartitions(int offset, int length) {
return computePartitioning(offset, length, false, true);
}
@Override
public ITypedRegion[] computeWritablePartitions() {
if (document == null) {
return new IOConsolePartition[0];
}
return computeWritablePartitions(0, document.getLength());
}
@Override
public ITypedRegion[] computeWritablePartitions(int offset, int length) {
return computePartitioning(offset, length, true, false);
}
@Override
public boolean isReadOnly(int offset, int length) {
final ITypedRegion[] readOnlyRegions = computeReadOnlyPartitions(offset, length);
int o = offset;
int end = offset + length;
for (ITypedRegion readOnlyRegion : readOnlyRegions) {
if (o < readOnlyRegion.getOffset()) {
return false;
}
o += readOnlyRegion.getLength();
if (o >= end) {
return true;
}
}
return false;
}
@Override
public boolean containsReadOnly(int offset, int length) {
return computeReadOnlyPartitions(offset, length).length > 0;
}
@Override
public int getPreviousOffsetByState(int offset, boolean searchWritable) {
synchronized (partitions) {
int partitionIndex = findPartitionCandidate(offset);
for (; partitionIndex >= 0; partitionIndex--) {
final IOConsolePartition partition = partitions.get(partitionIndex);
if (partition.isReadOnly() != searchWritable) {
return Math.min(partition.getOffset() + partition.getLength() - 1, offset);
}
}
}
return -1;
}
@Override
public int getNextOffsetByState(int offset, boolean searchWritable) {
synchronized (partitions) {
int partitionIndex = findPartitionCandidate(offset);
if (partitionIndex >= 0) {
for (; partitionIndex < partitions.size(); partitionIndex++) {
final IOConsolePartition partition = partitions.get(partitionIndex);
if (partition.isReadOnly() != searchWritable) {
return Math.max(partition.getOffset(), offset);
}
}
}
}
return document != null ? document.getLength() : 0;
}
/**
* Check if console currently interprets ASCII control characters.
*
* @return <code>true</code> if console interprets ASCII control characters
* @since 3.9
*/
public boolean isHandleControlCharacters() {
return controlCharacterPattern != null;
}
/**
* Enable or disable interpretation of ASCII control characters like backspace
* (<code>\b</code>).
*
* @param handleControlCharacters interpret control characters if
* <code>true</code>
* @since 3.9
*/
public void setHandleControlCharacters(boolean handleControlCharacters) {
if (handleControlCharacters) {
controlCharacterPattern = Pattern
.compile(carriageReturnAsControlCharacter ? CONTROL_CHARACTERS_WITH_CR_PATTERN_STR
: CONTROL_CHARACTERS_PATTERN_STR);
} else {
controlCharacterPattern = null;
}
}
/**
* Check if carriage returns (<code>\r</code>) are interpreted as control
* characters. They are also not interpreted if general control character
* handling is disabled.
*
* @return if <code>true</code> carriage returns are interpreted as control
* characters.
* @see #isHandleControlCharacters()
* @since 3.9
*/
public boolean isCarriageReturnAsControlCharacter() {
return carriageReturnAsControlCharacter;
}
/**
* If control characters are interpreted by this console carriage returns
* (<code>\r</code>) are either ignored (<code>false</code>) and usually handled
* as line break by connected console document or if <code>true</code>
* interpreted with there control character meaning.
* <p>
* Note: this option has no effect if control character interpretation is
* disabled in general.
* </p>
*
* @param carriageReturnAsControlCharacter set <code>false</code> to exclude
* carriage return from control
* character interpretation
* @see #setHandleControlCharacters(boolean)
* @since 3.9
*/
public void setCarriageReturnAsControlCharacter(boolean carriageReturnAsControlCharacter) {
this.carriageReturnAsControlCharacter = carriageReturnAsControlCharacter;
// reset to update control character pattern
setHandleControlCharacters(isHandleControlCharacters());
}
/**
* Get a partition by its index. Safe from out of bounds exceptions.
*
* @param index index of requested partition
* @return the requested partition or <code>null</code> if index is invalid
*/
private IOConsolePartition getPartitionByIndex(int index) {
return (index >= 0 && index < partitions.size()) ? partitions.get(index) : null;
}
/**
* Check if given partition is from type input partition.
*
* @param partition partition to check (not <code>null</code>)
* @return true if partition is an input partition
*/
private static boolean isInputPartition(IOConsolePartition partition) {
return IOConsolePartition.INPUT_PARTITION_TYPE.equals(partition.getType());
}
private static void log(Throwable t) {
ConsolePlugin.log(t);
}
private static void log(int status, String msg) {
ConsolePlugin.log(new Status(status, ConsolePlugin.getUniqueIdentifier(), msg));
}
private static void log(int status, String msg, Throwable t) {
ConsolePlugin.log(new Status(status, ConsolePlugin.getUniqueIdentifier(), msg, t));
}
/**
* For debug purpose. Check if whole document is partitioned, partitions are
* ordered by offset, every partition has length greater 0 and all writable
* input partitions are listed in {@link #inputPartitions}.
*/
private void checkPartitions() {
if (document == null) {
return;
}
synchronized (partitions) {
final List<IOConsolePartition> knownInputPartitions = new ArrayList<>(inputPartitions);
int offset = 0;
for (IOConsolePartition partition : partitions) {
Assert.isTrue(offset == partition.getOffset());
Assert.isTrue(partition.getLength() > 0);
offset += partition.getLength();
if (isInputPartition(partition) && !partition.isReadOnly()) {
Assert.isTrue(knownInputPartitions.remove(partition));
}
}
Assert.isTrue(offset == document.getLength());
Assert.isTrue(knownInputPartitions.isEmpty());
}
}
}