blob: d2f6fbc4b20ba3ab962dfdf3fd56502c43689e02 [file] [log] [blame]
/*******************************************************************************
* Copyright (c) 2000, 2019 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
*******************************************************************************/
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+)"; //$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+|\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) {
log(IStatus.ERROR, "Streams are already closed."); //$NON-NLS-1$
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 number of lines in the document exceeds the high water mark, the
* beginning of the document is trimmed until the number of lines equals 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.setOffset(length - lowWaterMark);
trimJob.schedule();
}
}
}
}
/**
* Clears the console content.
*/
public void clearBuffer() {
trimJob.setOffset(-1);
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)
*/
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 {
pendingPartitions.wait();
} catch (InterruptedException e) {
}
} else {
// if we are in UI thread we cannot lock it, so process queued output.
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 {
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 (isHandleControlCharacters()) {
applyStreamOutput(pendingCopy, size);
} else {
// Old implementation of output appending. The control character aware variant
// {@link #applyStreamOutput(List, int)} should do exactly the same if control
// character processing is disabled but since there is not so much time for
// testing in current development cycle the old implementation is used to
// process output when control character interpretation is disabled.
// TODO remove in next development cycle
final StringBuilder addedContent = new StringBuilder(size);
IOConsolePartition lastPartition = getPartitionByIndex(partitions.size() - 1);
int nextOffset = document.getLength();
for (PendingPartition pendingPartition : pendingCopy) {
if (lastPartition == null || lastPartition.getOutputStream() != pendingPartition.stream) {
lastPartition = new IOConsolePartition(nextOffset, pendingPartition.stream);
partitions.add(lastPartition);
}
final int pendingLength = pendingPartition.text.length();
lastPartition.setLength(lastPartition.getLength() + pendingLength);
nextOffset += pendingLength;
addedContent.append(pendingPartition.text);
}
try {
updateType = DocUpdateType.OUTPUT;
document.replace(document.getLength(), 0, addedContent.toString());
outputOffset += addedContent.length();
} catch (BadLocationException e) {
log(e);
}
}
}
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.
int nextWriteOffset = outputOffset;
final StringBuilder content = new StringBuilder(sizeHint);
int replaceLength = 0;
// the partition which contains the current output offset
IOConsolePartition atOutputPartition = null;
// the index of atOutputPartition in the partitions list
int 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;
}
while (textOffset < partEnd) {
// 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.getOutputStream() != pending.stream) {
// no partitions yet or last partition is incompatible to reuse -> add new one
atOutputPartition = new IOConsolePartition(outputOffset, pending.stream);
partitions.add(atOutputPartition);
atOutputPartitionIndex = partitions.size() - 1;
}
final int appendedLength = partEnd - textOffset;
content.append(pending.text, textOffset, partEnd);
atOutputPartition.setLength(atOutputPartition.getLength() + appendedLength);
outputOffset += appendedLength;
textOffset = partEnd;
} 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, pending.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(partEnd - textOffset,
atOutputPartition.getLength() - (outputOffset - atOutputPartition.getOffset()));
Assert.isTrue(chunkLength > 0); // do not remove since it can prevent an infinity loop
if (atOutputPartition.getOutputStream() != pending.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.getOutputStream() != pending.stream) {
outputPartition = new IOConsolePartition(outputOffset, pending.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(pending.text, textOffset, textOffset + chunkLength);
replaceLength += chunkLength;
textOffset += chunkLength;
outputOffset += chunkLength;
if (atOutputPartition != null
&& outputOffset == atOutputPartition.getOffset() + atOutputPartition.getLength()) {
atOutputPartitionIndex++;
atOutputPartition = getPartitionByIndex(atOutputPartitionIndex);
}
}
}
// 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;
final String controlCharacterMatch = controlCharacterMatcher.group();
final char controlCharacter = controlCharacterMatch.charAt(0);
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
final int outputLineStartOffset = findOutputLineStartOffset(outputOffset);
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);
break;
case '\r':
// move virtual output cursor to start of output line
outputOffset = findOutputLineStartOffset(outputOffset);
atOutputPartitionIndex = -1;
atOutputPartition = null;
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;
}
nextWriteOffset = outputOffset;
textOffset = controlCharacterMatcher.end();
}
}
}
applyOutputToDocument(content.toString(), nextWriteOffset, replaceLength);
}
/**
* 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 content collected content from output streams
* @param offset offset where content is inserted
* @param replaceLength length of overwritten old output
*/
private void applyOutputToDocument(String content, int offset, int replaceLength) {
if (content.length() > 0 || replaceLength > 0) {
if (ASSERT) {
Assert.isTrue(replaceLength <= content.length());
}
try {
updateType = DocUpdateType.OUTPUT;
document.replace(offset, replaceLength, content);
} 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 the line containing the given offset, or all output if -1.
*/
private int truncateOffset;
/**
* Creates a new job to trim the buffer.
*/
TrimJob() {
super("Trim Job"); //$NON-NLS-1$
setSystem(true);
}
/**
* Sets the trim offset.
*
* @param offset trims output up to the line containing the given offset
*/
public void setOffset(int offset) {
truncateOffset = offset;
}
@Override
public IStatus runInUIThread(IProgressMonitor monitor) {
synchronized (partitions) {
if (document == null) {
return Status.OK_STATUS;
}
int length = document.getLength();
if (truncateOffset < length) {
try {
if (truncateOffset < 0) {
// clear
updateType = DocUpdateType.TRIM;
document.set(""); //$NON-NLS-1$
} else {
// overflow
int cutoffLine = document.getLineOfOffset(truncateOffset);
int cutOffset = document.getLineOffset(cutoffLine);
// set the new length of the first partition
IOConsolePartition partition = (IOConsolePartition) getPartition(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) {
}
}
}
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));
}
/**
* 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());
}
}
}