/**
* Copyright (c) 2005-2012 by Appcelerator, Inc. All Rights Reserved.
* Licensed under the terms of the Eclipse Public License (EPL).
* Please see the license.txt included with this distribution for details.
* Any modifications to this file must keep this entire header intact.
*/
package com.aptana.interactive_console.console.ui.internal;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import org.eclipse.core.runtime.Assert;
import org.eclipse.debug.ui.console.IConsoleLineTracker;
import org.eclipse.jface.text.BadLocationException;
import org.eclipse.jface.text.Document;
import org.eclipse.jface.text.DocumentEvent;
import org.eclipse.jface.text.IDocument;
import org.eclipse.jface.text.IDocumentListener;
import org.eclipse.jface.text.IDocumentPartitioner;
import org.eclipse.jface.text.Region;
import org.eclipse.jface.text.TextUtilities;
import com.aptana.interactive_console.console.InterpreterResponse;
import com.aptana.interactive_console.console.ScriptConsoleHistory;
import com.aptana.interactive_console.console.ScriptConsolePrompt;
import com.aptana.interactive_console.console.ui.IConsoleStyleProvider;
import com.aptana.interactive_console.console.ui.ScriptConsolePartitioner;
import com.aptana.interactive_console.console.ui.ScriptStyleRange;
import com.aptana.shared_core.callbacks.ICallback;
import com.aptana.shared_core.log.Log;
import com.aptana.shared_core.string.FastStringBuffer;
import com.aptana.shared_core.structure.Tuple;
import com.aptana.shared_core.utils.DocCmd;
import com.aptana.shared_core.utils.RunInUiThread;
import com.aptana.shared_core.utils.TextSelectionUtils;
/**
* This class will listen to the document and will:
*
* - pass the commands to the handler
* - add the results from the handler
* - show the prompt
* - set the color of the console regions
*/
public class ScriptConsoleDocumentListener implements IDocumentListener {
private ICommandHandler handler;
private ScriptConsolePrompt prompt;
private ScriptConsoleHistory history;
private int offset;
/**
* Document to which this listener is attached.
*/
private IDocument doc;
private int disconnectionLevel = 0;
/**
* The time for the last change in the document that was listened in this console.
*/
private long lastChangeMillis;
/**
* The commands that should be initially set in the console
*/
private String initialCommands;
/**
* @return the last time the document that this console was listening to was changed.
*/
public long getLastChangeMillis() {
return lastChangeMillis;
}
/**
* Viewer for the document contained in this listener.
*/
private IScriptConsoleViewer2ForDocumentListener viewer;
/**
* Additional viewers for the same document.
*/
private List<WeakReference<IScriptConsoleViewer2ForDocumentListener>> otherViewers = new ArrayList<WeakReference<IScriptConsoleViewer2ForDocumentListener>>();
/**
* Strategy used for indenting / tabs
*/
private IHandleScriptAutoEditStrategy strategy;
/**
* Console line trackers (for hyperlinking)
*/
private List<IConsoleLineTracker> consoleLineTrackers;
public IHandleScriptAutoEditStrategy getIndentStrategy() {
return strategy;
}
/**
* Stops listening changes in one document and starts listening another one.
*
* @param oldDoc may be null (if not null, this class will stop listening changes in it).
* @param newDoc the document that should be listened from now on.
*/
protected synchronized void reconnect(IDocument oldDoc, IDocument newDoc) {
Assert.isTrue(disconnectionLevel == 0);
if (oldDoc != null) {
oldDoc.removeDocumentListener(this);
}
newDoc.addDocumentListener(this);
this.doc = newDoc;
}
/**
* Stop listening to changes (so that we're able to change the document in this class without having
* any loops back into the function that will change it)
*/
protected synchronized void startDisconnected() {
if (disconnectionLevel == 0) {
doc.removeDocumentListener(this);
}
disconnectionLevel += 1;
}
/**
* Start listening to changes again.
*/
protected synchronized void stopDisconnected() {
disconnectionLevel -= 1;
if (disconnectionLevel == 0) {
doc.addDocumentListener(this);
}
}
/**
* Clear the document and show the initial prompt.
* @param addInitialCommands indicates if the initial commands should be appended to the document.
*/
public void clear(boolean addInitialCommands) {
startDisconnected();
try {
doc.set(""); //$NON-NLS-1$
appendInvitation(true);
} finally {
stopDisconnected();
}
if (addInitialCommands) {
try {
doc.replace(doc.getLength(), 0, this.initialCommands);
} catch (BadLocationException e) {
Log.log(e);
}
}
}
/**
* Adds some other viewer for the same document.
*
* @param scriptConsoleViewer this is the viewer that should be added as a second viewer for the same
* document.
*/
public void addViewer(IScriptConsoleViewer2ForDocumentListener scriptConsoleViewer) {
this.otherViewers.add(new WeakReference<IScriptConsoleViewer2ForDocumentListener>(scriptConsoleViewer));
}
/**
* Constructor
*
* @param viewer this is the viewer to which this listener is attached. It's the main viewer. Other viewers
* may be added later through addViewer() for sharing the same listener and being properly updated.
*
* @param handler this is the object that'll handle the commands
* @param prompt shows the prompt to the user
* @param history keeps track of the commands added by the user.
* @param initialCommands the commands that should be initially added
*/
public ScriptConsoleDocumentListener(IScriptConsoleViewer2ForDocumentListener viewer, ICommandHandler handler,
ScriptConsolePrompt prompt, ScriptConsoleHistory history, List<IConsoleLineTracker> consoleLineTrackers,
String initialCommands, IHandleScriptAutoEditStrategy strategy) {
this.lastChangeMillis = System.currentTimeMillis();
this.strategy = strategy;
this.prompt = prompt;
this.handler = handler;
this.history = history;
this.viewer = viewer;
this.offset = 0;
this.doc = null;
this.consoleLineTrackers = consoleLineTrackers;
this.initialCommands = initialCommands;
}
/**
* Set the document that this class should listen.
*
* @param doc the document that should be used in the console.
*/
public void setDocument(IDocument doc) {
reconnect(this.doc, doc);
}
/**
* Ignore
*/
public void documentAboutToBeChanged(DocumentEvent event) {
}
/**
* Process the result that came from pushing some text to the interpreter.
*
* @param result the response from the interpreter after sending some command for it to process.
*/
protected void processResult(final InterpreterResponse result) {
if (result != null) {
addToConsoleView(result.out, true);
addToConsoleView(result.err, false);
history.commit();
try {
offset = getLastLineLength();
} catch (BadLocationException e) {
Log.log(e);
}
}
appendInvitation(false);
}
/**
* Adds some text that came as an output to stdout or stderr to the console.
*
* @param out the text that should be added
* @param stdout true if it came from stdout and also if it came from stderr
*/
private void addToConsoleView(String out, boolean stdout) {
if (out.length() == 0) {
return; //nothing to add!
}
int start = doc.getLength();
IConsoleStyleProvider styleProvider = viewer.getStyleProvider();
Tuple<List<ScriptStyleRange>, String> style = null;
if (styleProvider != null) {
if (stdout) {
style = styleProvider.createInterpreterOutputStyle(out, start);
} else { //stderr
style = styleProvider.createInterpreterErrorStyle(out, start);
}
if (style != null) {
for (ScriptStyleRange s : style.o1) {
addToPartitioner(s);
}
}
}
if (style != null) {
appendText(style.o2);
}
TextSelectionUtils ps = new TextSelectionUtils(doc, start);
int cursorLine = ps.getCursorLine();
int numberOfLines = doc.getNumberOfLines();
//right after appending the text, let's notify line trackers
for (int i = cursorLine; i < numberOfLines; i++) {
try {
int offset = ps.getLineOffset(i);
int endOffset = ps.getEndLineOffset(i);
Region region = new Region(offset, endOffset - offset);
for (IConsoleLineTracker lineTracker : this.consoleLineTrackers) {
lineTracker.lineAppended(region);
}
} catch (Exception e) {
Log.log(e);
}
}
}
/**
* Adds a given style range to the partitioner.
*
* Note that the style must be added before the actual text is added! (because as
* soon as it's added, the style is asked for).
*
* @param style the style to be added.
*/
private void addToPartitioner(ScriptStyleRange style) {
IDocumentPartitioner partitioner = this.doc.getDocumentPartitioner();
if (partitioner instanceof ScriptConsolePartitioner) {
ScriptConsolePartitioner scriptConsolePartitioner = (ScriptConsolePartitioner) partitioner;
scriptConsolePartitioner.addRange(style);
}
}
/**
* Should be called right after adding some text to the console (it'll actually go on,
* remove the text just added and add it line-by-line in the document so that it can be
* correctly treated in the console).
*
* @param offset the offset where the addition took place
* @param text the text that should be adedd
*/
protected void proccessAddition(int offset, String text) {
//we have to do some gymnastics here to add line-by-line the contents that the user entered.
//(mostly because it may have been a copy/paste with multi-lines)
String indentString = "";
boolean addedNewLine = false;
boolean addedParen = false;
boolean addedCloseParen = false;
int addedLen = text.length();
if (addedLen == 1) {
if (text.equals("\r") || text.equals("\n")) {
addedNewLine = true;
} else if (text.equals("(")) {
addedParen = true;
} else if (text.equals(")")) {
addedCloseParen = true;
}
} else if (addedLen == 2) {
if (text.equals("\r\n")) {
addedNewLine = true;
}
}
String delim = getDelimeter();
int newDeltaCaretPosition = doc.getLength() - (offset + text.length());
//1st, remove the text the user just entered (and enter it line-by-line later)
try {
// Remove the just entered text
doc.replace(offset, text.length(), ""); //$NON-NLS-1$
// Is the current offset in the command line
// NB we do this after the above as the pasted text may have new lines in it
boolean offset_in_command_line = offset >= getCommandLineOffset();
// If the offset isn't in the command line, then just append to the existing
// command line text
if (!offset_in_command_line) {
offset = newDeltaCaretPosition = getCommandLineOffset();
// Remove any existing command line text and prepend it to the text
// we're inserting
text = doc.get(getCommandLineOffset(), getCommandLineLength()) + text;
doc.replace(getCommandLineOffset(), getCommandLineLength(), "");
} else {
// paste is within the command line
text = text + doc.get(offset, doc.getLength() - offset);
doc.replace(offset, doc.getLength() - offset, "");
}
} catch (BadLocationException e) {
text = "";
Log.log(e);
}
text = text.replaceAll("\r\n|\n|\r", delim); //$NON-NLS-1$
//now, add it line-by-line (it won't even get into the loop if there's no
//new line in the text added).
int start = 0;
int index = -1;
List<String> commands = new ArrayList<String>();
while ((index = text.indexOf(delim, start)) != -1) {
String cmd = text.substring(start, index);
cmd = convertTabs(cmd);
commands.add(cmd);
start = index + delim.length();
}
final String[] finalIndentString = new String[] { indentString };
if (commands.size() > 0) {
//Note that we'll disconnect from the document here and reconnect when the last line is executed.
startDisconnected();
String cmd = commands.get(0);
execCommand(addedNewLine, delim, finalIndentString, cmd, commands, 0, text, addedParen, start,
addedCloseParen, newDeltaCaretPosition);
} else {
onAfterAllLinesHandled(text, addedParen, start, offset, addedCloseParen, finalIndentString[0],
newDeltaCaretPosition);
}
}
/**
* Here is where we run things not using the UI thread. It's a recursive function. In summary, it'll
* run each line in the commands received in a new thread, and as each finishes, it calls itself again
* for the next command. The last command will reconnect to the document.
*
* Exceptions had to be locally handled, because they're not well tolerated under this scenario
* (if on of the callbacks fail, the others won't be executed and we'd get into a situation
* where the shell becomes unusable).
*/
private void execCommand(final boolean addedNewLine, final String delim, final String[] finalIndentString,
final String cmd, final List<String> commands, final int currentCommand, final String text,
final boolean addedParen, final int start, final boolean addedCloseParen, final int newDeltaCaretPosition) {
applyStyleToUserAddedText(cmd, doc.getLength());
//the cmd could be something as '\n'
appendText(cmd);
//and the command line the actual contents to be executed at this time
final String commandLine = getCommandLine();
history.update(commandLine);
// handle the command line:
// When the user presses a return and goes to a new line, the contents of the current line are sent to
// the interpreter (and its results properly handled).
appendText(getDelimeter());
final boolean finalAddedNewLine = addedNewLine;
final String finalDelim = delim;
final ICallback<Object, InterpreterResponse> onResponseReceived = new ICallback<Object, InterpreterResponse>() {
public Object call(final InterpreterResponse arg) {
//When we receive the response, we must handle it in the UI thread.
Runnable runnable = new Runnable() {
public void run() {
try {
processResult(arg);
if (finalAddedNewLine) {
IDocument historyDoc = history.getAsDoc();
int currHistoryLen = historyDoc.getLength();
if (currHistoryLen > 0) {
DocCmd docCmd = new DocCmd(currHistoryLen - 1, 0, finalDelim);
strategy.customizeNewLine(historyDoc, docCmd);
finalIndentString[0] = docCmd.text.replaceAll("\\r\\n|\\n|\\r", ""); //remove any new line added!
if (currHistoryLen != historyDoc.getLength()) {
Log.log("Error: the document passed to the customizeNewLine should not be changed!");
}
}
}
} catch (Throwable e) {
//Yeap, it can never fail!
Log.log(e);
}
if (currentCommand + 1 < commands.size()) {
execCommand(finalAddedNewLine, finalDelim, finalIndentString,
commands.get(currentCommand + 1), commands, currentCommand + 1, text, addedParen,
start, addedCloseParen, newDeltaCaretPosition);
} else {
//last one
try {
onAfterAllLinesHandled(text, addedParen, start, offset, addedCloseParen,
finalIndentString[0], newDeltaCaretPosition);
} finally {
//We must disconnect
stopDisconnected(); //reconnect with the document
}
}
}
};
RunInUiThread.async(runnable);
return null;
}
};
final ICallback<Object, Tuple<String, String>> onContentsReceived = new ICallback<Object, Tuple<String, String>>() {
public Object call(final Tuple<String, String> result) {
Runnable runnable = new Runnable() {
public void run() {
if (result != null) {
addToConsoleView(result.o1, true);
addToConsoleView(result.o2, false);
revealEndOfDocument();
}
}
};
RunInUiThread.async(runnable);
return null;
}
};
//Handle the command in a thread that doesn't block the U/I.
new Thread() {
public void run() {
handler.handleCommand(commandLine, onResponseReceived, onContentsReceived);
}
}.start();
}
/**
* This method should be called after all the lines received were processed.
*/
private void onAfterAllLinesHandled(final String finalText, final boolean finalAddedParen, final int finalStart,
final int finalOffset, final boolean finalAddedCloseParen, final String finalIndentString,
final int finalNewDeltaCaretPosition) {
boolean shiftsCaret = true;
String newText = finalText.substring(finalStart, finalText.length());
if (finalAddedParen) {
String cmdLine = getCommandLine();
Document parenDoc = new Document(cmdLine + newText);
int currentOffset = cmdLine.length() + 1;
DocCmd docCmd = new DocCmd(currentOffset, 0, "(");
docCmd.shiftsCaret = true;
try {
strategy.customizeParenthesis(parenDoc, docCmd);
} catch (BadLocationException e) {
Log.log(e);
}
newText = docCmd.text + newText.substring(1);
if (!docCmd.shiftsCaret) {
shiftsCaret = false;
setCaretOffset(finalOffset + (docCmd.caretOffset - currentOffset));
}
} else if (finalAddedCloseParen) {
String cmdLine = getCommandLine();
String existingDoc = cmdLine + finalText.substring(1);
int cmdLineOffset = cmdLine.length();
if (existingDoc.length() > cmdLineOffset) {
Document parenDoc = new Document(existingDoc);
DocCmd docCmd = new DocCmd(cmdLineOffset, 0, ")");
docCmd.shiftsCaret = true;
boolean canSkipOpenParenthesis;
try {
canSkipOpenParenthesis = strategy.canSkipCloseParenthesis(parenDoc, docCmd);
} catch (BadLocationException e) {
canSkipOpenParenthesis = false;
Log.log(e);
}
if (canSkipOpenParenthesis) {
shiftsCaret = false;
setCaretOffset(finalOffset + 1);
newText = newText.substring(1);
}
}
}
//and now add the last line (without actually handling it).
String cmd = finalIndentString + newText;
cmd = convertTabs(cmd);
applyStyleToUserAddedText(cmd, doc.getLength());
appendText(cmd);
if (shiftsCaret) {
setCaretOffset(doc.getLength() - finalNewDeltaCaretPosition);
}
history.update(getCommandLine());
}
private String convertTabs(String cmd) {
return strategy.convertTabs(cmd);
}
/**
* Applies the style in the text for the contents that've been just added.
*
* @param cmd
* @param offset2
*/
private void applyStyleToUserAddedText(String cmd, int offset2) {
IConsoleStyleProvider styleProvider = viewer.getStyleProvider();
if (styleProvider != null) {
ScriptStyleRange style = styleProvider.createUserInputStyle(cmd, offset2);
if (style != null) {
addToPartitioner(style);
}
}
}
/**
* Whenever the document changes, we stop listening to change the document from
* within this listener (passing commands to the handler if needed, getting results, etc).
*/
public void documentChanged(DocumentEvent event) {
lastChangeMillis = System.currentTimeMillis();
startDisconnected();
try {
int eventOffset = event.getOffset();
String eventText = event.getText();
proccessAddition(eventOffset, eventText);
} finally {
stopDisconnected();
}
}
/**
* Appends some text at the end of the document.
*
* @param text the text to be added.
*/
protected void appendText(String text) {
int initialOffset = doc.getLength();
try {
doc.replace(initialOffset, 0, text);
} catch (BadLocationException e) {
Log.log(e);
}
}
/**
* Shows the prompt for the user (e.g.: >>>)
*/
protected void appendInvitation(boolean async) {
int start = doc.getLength();
String promptStr = prompt.toString();
IConsoleStyleProvider styleProvider = viewer.getStyleProvider();
if (styleProvider != null) {
ScriptStyleRange style = styleProvider.createPromptStyle(promptStr, start);
if (style != null) {
addToPartitioner(style);
}
}
appendText(promptStr); //caret already updated
setCaretOffset(doc.getLength(), async);
revealEndOfDocument();
}
/**
* Shows the end of the document for the main viewer and all the related viewer for the same document.
*/
private void revealEndOfDocument() {
viewer.revealEndOfDocument();
for (Iterator<WeakReference<IScriptConsoleViewer2ForDocumentListener>> it = otherViewers.iterator(); it
.hasNext();) {
WeakReference<IScriptConsoleViewer2ForDocumentListener> ref = it.next();
IScriptConsoleViewer2ForDocumentListener v = ref.get();
if (v == null) {
it.remove();
} else {
v.revealEndOfDocument();
}
}
}
private void setCaretOffset(int offset) {
setCaretOffset(offset, false);
}
/**
* Sets the caret offset to the passed offset for the main viewer and all the related viewer for the same document.
* @param offset the offset to which the caret should be moved
*/
private void setCaretOffset(int offset, boolean async) {
viewer.setCaretOffset(offset, async);
for (Iterator<WeakReference<IScriptConsoleViewer2ForDocumentListener>> it = otherViewers.iterator(); it
.hasNext();) {
WeakReference<IScriptConsoleViewer2ForDocumentListener> ref = it.next();
IScriptConsoleViewer2ForDocumentListener v = ref.get();
if (v == null) {
it.remove();
} else {
v.setCaretOffset(offset, async);
}
}
}
/**
* @return the delimiter to be used to add new lines to the console.
*/
public String getDelimeter() {
return TextUtilities.getDefaultLineDelimiter(doc);
}
/**
* @return the length of the last line
*/
public int getLastLineLength() throws BadLocationException {
int lastLine = doc.getNumberOfLines() - 1;
return doc.getLineLength(lastLine);
}
/**
* @return the offset where the last line starts
* @throws BadLocationException
*/
public int getLastLineOffset() throws BadLocationException {
int lastLine = doc.getNumberOfLines() - 1;
return doc.getLineOffset(lastLine);
}
public int getLastLineReadOnlySize() {
return offset + prompt.toString().length();
}
public int getCommandLineOffset() throws BadLocationException {
int lastLine = doc.getNumberOfLines() - 1;
return doc.getLineOffset(lastLine) + getLastLineReadOnlySize();
}
/**
* @return the length of the current command line (all the currently
* editable area)
*
* @throws BadLocationException
*/
public int getCommandLineLength() throws BadLocationException {
int lastLine = doc.getNumberOfLines() - 1;
return doc.getLineLength(lastLine) - getLastLineReadOnlySize();
}
/**
* @return the command line that the user entered.
* @throws BadLocationException
*/
public String getCommandLine() {
int commandLineOffset;
int commandLineLength;
try {
commandLineOffset = getCommandLineOffset();
commandLineLength = getCommandLineLength();
} catch (BadLocationException e1) {
Log.log(e1);
return "";
}
if (commandLineLength < 0) {
return "";
}
try {
return doc.get(commandLineOffset, commandLineLength);
} catch (BadLocationException e) {
String msg = new FastStringBuffer(60).append("Error: bad location: offset:").append(commandLineOffset)
.append(" text:").append(commandLineLength).toString();
Log.log(msg);
return "";
}
}
/**
* Sets the current command line to be executed (but without executing it).
* Used by the up/down arrow to set a previous/next command.
*
* @param command this is the command that should be in the command line.
*
* @throws BadLocationException
*/
public void setCommandLine(String command) throws BadLocationException {
doc.replace(getCommandLineOffset(), getCommandLineLength(), command);
}
}