Skip to content

Commit 2b85904

Browse files
committed
Merge pull request #220 from scijava/script-repl
Add a shiny new REPL for the script framework
2 parents 5d9e1bb + cb7067e commit 2b85904

File tree

4 files changed

+685
-11
lines changed

4 files changed

+685
-11
lines changed

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

Lines changed: 274 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,37 +30,88 @@
3030
*/
3131
package org.scijava.script;
3232

33+
import java.lang.reflect.Method;
34+
35+
import javax.script.Bindings;
36+
import javax.script.Compilable;
37+
import javax.script.CompiledScript;
38+
import javax.script.ScriptContext;
3339
import javax.script.ScriptEngine;
3440
import javax.script.ScriptException;
3541

42+
import org.scijava.log.LogService;
43+
import org.scijava.plugin.Parameter;
3644
import org.scijava.prefs.PrefService;
3745

3846
/**
3947
* 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>
4052
*
4153
* @author Johannes Schindelin
54+
* @author Curtis Rueden
4255
*/
4356
public class DefaultScriptInterpreter implements ScriptInterpreter {
4457

4558
private final ScriptLanguage language;
4659
private final ScriptEngine engine;
4760
private final History history;
4861

62+
@Parameter(required = false)
63+
private PrefService prefs;
64+
65+
@Parameter(required = false)
66+
private LogService log;
67+
68+
private final StringBuilder buffer;
69+
private int pendingLineCount;
70+
private boolean expectingMoreInput;
71+
4972
/**
50-
* Constructs a new {@link DefaultScriptInterpreter}.
51-
*
52-
* @param scriptService the script service
53-
* @param language the script language
73+
* @deprecated Use {@link #DefaultScriptInterpreter(ScriptLanguage)} instead.
5474
*/
75+
@Deprecated
76+
@SuppressWarnings("unused")
5577
public DefaultScriptInterpreter(final PrefService prefs,
5678
final ScriptService scriptService, final ScriptLanguage language)
5779
{
80+
this(language);
81+
}
82+
83+
/**
84+
* Creates a new script interpreter for the given script language.
85+
*
86+
* @param language {@link ScriptLanguage} of the interpreter
87+
*/
88+
public DefaultScriptInterpreter(final ScriptLanguage language) {
89+
this(language, null);
90+
}
91+
92+
/**
93+
* Creates a new script interpreter for the given script language, using the
94+
* specified script engine.
95+
*
96+
* @param language {@link ScriptLanguage} of the interpreter
97+
* @param engine {@link ScriptEngine} to use, or null for the specified
98+
* language's default engine
99+
*/
100+
public DefaultScriptInterpreter(final ScriptLanguage language,
101+
final ScriptEngine engine)
102+
{
103+
language.getContext().inject(this);
58104
this.language = language;
59-
engine = language.getScriptEngine();
60-
history = new History(prefs, engine.getClass().getName());
105+
this.engine = engine == null ? language.getScriptEngine() : engine;
106+
history = prefs == null ? null :
107+
new History(prefs, this.engine.getClass().getName());
61108
readHistory();
109+
buffer = new StringBuilder();
110+
reset();
62111
}
63112

113+
// -- ScriptInterpreter methods --
114+
64115
@Override
65116
public synchronized void readHistory() {
66117
if (history == null) return;
@@ -83,10 +134,100 @@ public synchronized String walkHistory(final String currentCommand,
83134
}
84135

85136
@Override
86-
public void eval(final String command) throws ScriptException {
87-
if (history != null) history.add(command);
88-
if (engine == null) throw new java.lang.IllegalArgumentException();
89-
engine.eval(command);
137+
public Object eval(final String command) throws ScriptException {
138+
addToHistory(command);
139+
return engine.eval(command);
140+
}
141+
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;
90231
}
91232

92233
@Override
@@ -99,4 +240,127 @@ public ScriptEngine getEngine() {
99240
return engine;
100241
}
101242

243+
@Override
244+
public Bindings getBindings() {
245+
return engine.getBindings(ScriptContext.ENGINE_SCOPE);
246+
}
247+
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+
102366
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,12 @@ private synchronized void initScriptLanguageIndex() {
351351
index.add(factory, true);
352352
}
353353

354+
// Inject the context into languages which need it: the
355+
// wrapped engine factories from the ScriptEngineManager.
356+
for (final ScriptLanguage language : index) {
357+
if (language.getContext() == null) language.setContext(getContext());
358+
}
359+
354360
scriptLanguageIndex = index;
355361
}
356362

0 commit comments

Comments
 (0)