Skip to content

Commit 7607c0f

Browse files
committed
ScriptInterpreter: allow multi-line evaluation
This adds new methods designed to support evaluation of multi-line statements which are fed to the interpreter one line at a time. The approach works according to a strategy implemented by Jason Sachs on StackOverflow at: http://stackoverflow.com/a/5598207 This functionality will be useful for improving the behavior of REPL (Read-Eval-Print-Loop) shells backed by this interpreter.
1 parent edd3c34 commit 7607c0f

File tree

2 files changed

+273
-2
lines changed

2 files changed

+273
-2
lines changed

src/main/java/org/scijava/script/DefaultScriptInterpreter.java

Lines changed: 230 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,17 +30,28 @@
3030
*/
3131
package org.scijava.script;
3232

33+
import java.lang.reflect.Method;
34+
3335
import javax.script.Bindings;
36+
import javax.script.Compilable;
37+
import javax.script.CompiledScript;
3438
import javax.script.ScriptContext;
3539
import javax.script.ScriptEngine;
3640
import javax.script.ScriptException;
3741

42+
import org.scijava.log.LogService;
43+
import org.scijava.plugin.Parameter;
3844
import org.scijava.prefs.PrefService;
3945

4046
/**
4147
* The default implementation of a {@link ScriptInterpreter}.
48+
* <p>
49+
* Credit to Jason Sachs for the multi-line evaluation (see
50+
* <a href="http://stackoverflow.com/a/5598207">his post on StackOverflow</a>).
51+
* </p>
4252
*
4353
* @author Johannes Schindelin
54+
* @author Curtis Rueden
4455
*/
4556
public class DefaultScriptInterpreter implements ScriptInterpreter {
4657

@@ -51,6 +62,13 @@ public class DefaultScriptInterpreter implements ScriptInterpreter {
5162
@Parameter(required = false)
5263
private PrefService prefs;
5364

65+
@Parameter(required = false)
66+
private LogService log;
67+
68+
private final StringBuilder buffer;
69+
private int pendingLineCount;
70+
private boolean expectingMoreInput;
71+
5472
/**
5573
* @deprecated Use {@link #DefaultScriptInterpreter(ScriptLanguage)} instead.
5674
*/
@@ -88,6 +106,8 @@ public DefaultScriptInterpreter(final ScriptLanguage language,
88106
history = prefs == null ? null :
89107
new History(prefs, this.engine.getClass().getName());
90108
readHistory();
109+
buffer = new StringBuilder();
110+
reset();
91111
}
92112

93113
// -- ScriptInterpreter methods --
@@ -115,11 +135,101 @@ public synchronized String walkHistory(final String currentCommand,
115135

116136
@Override
117137
public Object eval(final String command) throws ScriptException {
118-
if (history != null) history.add(command);
119-
if (engine == null) throw new java.lang.IllegalArgumentException();
138+
addToHistory(command);
120139
return engine.eval(command);
121140
}
122141

142+
/**
143+
* {@inheritDoc}
144+
* <p>
145+
* This implementation from Jason Sachs uses the following strategy:
146+
* </p>
147+
* <ul>
148+
* <li>Keep a pending list of input lines not yet evaluated.</li>
149+
* <li>Try compiling (but not evaluating) the pending input lines.
150+
* <ul>
151+
* <li>If the compilation is OK, we may be able to execute pending input
152+
* lines.</li>
153+
* <li>If the compilation throws an exception, and there is an indication of
154+
* the position (line + column number) of the error, and this matches the end
155+
* of the pending input, then that's a clue that we're expecting more input,
156+
* so swallow the exception and wait for the next line.</li>
157+
* <li>Otherwise, we either don't know where the error is, or it happened
158+
* prior to the end of the pending input, so rethrow the exception.</li>
159+
* </ul>
160+
* </li>
161+
* <li>If we are not expecting any more input lines, and we only have one line
162+
* of pending input, then evaluate it and restart.</li>
163+
* <li>If we are not expecting any more input lines, and the last one is a
164+
* blank one, and we have more than one line of pending input, then evaluate
165+
* it and restart. Python's interactive shell seems to do this.</li>
166+
* <li>Otherwise, keep reading input lines.</li>
167+
* </ul>
168+
* <p>
169+
* This helps avoid certain problems:
170+
* </p>
171+
* <ul>
172+
* <li>users getting annoyed having to enter extra blank lines after
173+
* single-line inputs</li>
174+
* <li>users entering a long multi-line statement and only find out after the
175+
* fact that there was a syntax error in the 2nd line.</li>
176+
* </ul>
177+
* <p>
178+
* For further details, see <a href="http://stackoverflow.com/a/5598207">SO
179+
* #5584674</a>.
180+
* </p>
181+
* </p>
182+
*/
183+
@Override
184+
public Object interpret(final String line) throws ScriptException {
185+
if (line.isEmpty()) {
186+
if (!shouldEvaluatePendingInput(true)) return MORE_INPUT_PENDING;
187+
}
188+
189+
pendingLineCount++;
190+
buffer.append(line);
191+
buffer.append("\n");
192+
193+
if (!(engine instanceof Compilable)) {
194+
// Not a compilable language.
195+
// Evaluate directly, with no multi-line statements possible.
196+
try {
197+
return eval(buffer.toString());
198+
}
199+
finally {
200+
reset();
201+
}
202+
}
203+
204+
final CompiledScript cs = tryCompiling(buffer.toString(),
205+
getPendingLineCount(), line.length());
206+
207+
if (cs == null) {
208+
// Command did not compile.
209+
// Assume it is incomplete and wait for more input on the next line.
210+
return MORE_INPUT_PENDING;
211+
}
212+
if (!shouldEvaluatePendingInput(line.isEmpty())) {
213+
// We are still expecting more input.
214+
return MORE_INPUT_PENDING;
215+
}
216+
// Command is complete; evaluate the compiled script.
217+
try {
218+
addToHistory(buffer.toString());
219+
return cs.eval();
220+
}
221+
finally {
222+
reset();
223+
}
224+
}
225+
226+
@Override
227+
public void reset() {
228+
buffer.setLength(0);
229+
pendingLineCount = 0;
230+
expectingMoreInput = false;
231+
}
232+
123233
@Override
124234
public ScriptLanguage getLanguage() {
125235
return language;
@@ -135,4 +245,122 @@ public Bindings getBindings() {
135245
return engine.getBindings(ScriptContext.ENGINE_SCOPE);
136246
}
137247

248+
@Override
249+
public boolean isReady() {
250+
return buffer.length() == 0;
251+
}
252+
253+
@Override
254+
public boolean isExpectingMoreInput() {
255+
return expectingMoreInput;
256+
}
257+
258+
// -- Helper methods --
259+
260+
private void addToHistory(final String command) {
261+
if (history != null) history.add(command);
262+
}
263+
264+
/**
265+
* @return number of lines pending execution
266+
*/
267+
private int getPendingLineCount() {
268+
return pendingLineCount;
269+
}
270+
271+
/**
272+
* @param lineIsEmpty whether the last line is empty
273+
* @return whether we should evaluate the pending input. The default behavior
274+
* is to evaluate if we only have one line of input, or if the user
275+
* enters a blank line. This behavior should be overridden where
276+
* appropriate.
277+
*/
278+
private boolean shouldEvaluatePendingInput(final boolean lineIsEmpty) {
279+
if (isExpectingMoreInput()) return false;
280+
return getPendingLineCount() == 1 || lineIsEmpty;
281+
}
282+
283+
private CompiledScript tryCompiling(final String string, final int lineCount,
284+
final int lastLineLength) throws ScriptException
285+
{
286+
CompiledScript result = null;
287+
try {
288+
final Compilable c = (Compilable) engine;
289+
result = c.compile(string);
290+
}
291+
catch (final ScriptException se) {
292+
boolean rethrow = true;
293+
if (se.getCause() != null) {
294+
final Integer col = columnNumber(se);
295+
final Integer line = lineNumber(se);
296+
// swallow the exception if it occurs at the last character
297+
// of the input (we may need to wait for more lines)
298+
if (isLastCharacter(col, line, lineCount, lastLineLength)) {
299+
rethrow = false;
300+
}
301+
else if (log != null && log.isDebug()) {
302+
final String msg = se.getCause().getMessage();
303+
log.debug("L" + line + " C" + col + "(" + lineCount + "," +
304+
lastLineLength + "): " + msg);
305+
log.debug("in '" + string + "'");
306+
}
307+
}
308+
309+
if (rethrow) {
310+
reset();
311+
throw se;
312+
}
313+
}
314+
315+
expectingMoreInput = result == null;
316+
return result;
317+
}
318+
319+
private boolean isLastCharacter(final Integer col, final Integer line,
320+
final int lineCount, final int lastLineLength)
321+
{
322+
if (col == null || line == null) return false;
323+
final int colNo = col.intValue(), lineNo = line.intValue();
324+
return lineNo == lineCount && colNo == lastLineLength ||
325+
lineNo == lineCount + 1 && colNo == 0;
326+
}
327+
328+
private Integer columnNumber(final ScriptException se) {
329+
if (se.getColumnNumber() >= 0) return se.getColumnNumber();
330+
return callMethod(se.getCause(), "columnNumber", Integer.class);
331+
}
332+
333+
private Integer lineNumber(final ScriptException se) {
334+
if (se.getLineNumber() >= 0) return se.getLineNumber();
335+
return callMethod(se.getCause(), "lineNumber", Integer.class);
336+
}
337+
338+
private static Method getMethod(final Object object,
339+
final String methodName)
340+
{
341+
try {
342+
return object.getClass().getMethod(methodName);
343+
}
344+
catch (final NoSuchMethodException e) {
345+
// gulp
346+
return null;
347+
}
348+
}
349+
350+
private static <T> T callMethod(final Object object, final String methodName,
351+
final Class<T> cl)
352+
{
353+
try {
354+
final Method m = getMethod(object, methodName);
355+
if (m != null) {
356+
final Object result = m.invoke(object);
357+
return cl.cast(result);
358+
}
359+
}
360+
catch (final Exception e) {
361+
e.printStackTrace();
362+
}
363+
return null;
364+
}
365+
138366
}

src/main/java/org/scijava/script/ScriptInterpreter.java

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,16 @@
4040
* The contract for script interpreters.
4141
*
4242
* @author Johannes Schindelin
43+
* @author Curtis Rueden
4344
*/
4445
public interface ScriptInterpreter {
4546

47+
/**
48+
* A special object returned by {@link #interpret(String)} when the
49+
* interpreter is expecting additional input before finishing the evaluation.
50+
*/
51+
Object MORE_INPUT_PENDING = new Object();
52+
4653
/**
4754
* Reads the persisted history of the current script interpreter.
4855
*/
@@ -72,6 +79,26 @@ public interface ScriptInterpreter {
7279
*/
7380
Object eval(String command) throws ScriptException;
7481

82+
/**
83+
* Interprets the given line of code, which might be part of a multi-line
84+
* statement.
85+
*
86+
* @param line line of code to interpret
87+
* @return value of the line, or {@link #MORE_INPUT_PENDING} if there is still
88+
* pending input
89+
* @throws ScriptException in case of an exception
90+
*/
91+
Object interpret(String line) throws ScriptException;
92+
93+
/**
94+
* Clears the buffer of not-yet-evaluated lines of code, accumulated from
95+
* previous calls to {@link #interpret}. In other words: start over with a new
96+
* (potentially multi-line) statement, discarding the current partial one.
97+
*
98+
* @see #interpret
99+
*/
100+
void reset();
101+
75102
/**
76103
* Returns the associated {@link ScriptLanguage}.
77104
*/
@@ -90,4 +117,20 @@ public interface ScriptInterpreter {
90117
*/
91118
Bindings getBindings();
92119

120+
/**
121+
* @return whether the interpreter is ready for a brand new statement.
122+
* @see #interpret(String)
123+
*/
124+
boolean isReady();
125+
126+
/**
127+
* @return whether the interpreter expects more input. A true value means
128+
* there is definitely more input needed. A false value means no more
129+
* input is needed, but it may not yet be appropriate to evaluate all
130+
* the pending lines. (there's some ambiguity depending on the
131+
* language)
132+
* @see #interpret(String)
133+
*/
134+
boolean isExpectingMoreInput();
135+
93136
}

0 commit comments

Comments
 (0)