001package jmri.script;
002
003import java.io.File;
004import java.io.FileInputStream;
005import java.io.FileNotFoundException;
006import java.io.IOException;
007import java.io.InputStream;
008import java.io.InputStreamReader;
009import java.io.Reader;
010import java.nio.charset.StandardCharsets;
011import java.util.HashMap;
012import java.util.Properties;
013
014import javax.annotation.CheckForNull;
015import javax.annotation.Nonnull;
016import javax.script.Bindings;
017import javax.script.ScriptContext;
018import javax.script.ScriptEngine;
019import javax.script.ScriptEngineFactory;
020import javax.script.ScriptEngineManager;
021import javax.script.ScriptException;
022import javax.script.SimpleBindings;
023import javax.script.SimpleScriptContext;
024import jmri.AddressedProgrammerManager;
025import jmri.AudioManager;
026import jmri.BlockManager;
027import jmri.CommandStation;
028import jmri.GlobalProgrammerManager;
029import jmri.InstanceManager;
030import jmri.InstanceManagerAutoDefault;
031import jmri.Light;
032import jmri.LightManager;
033import jmri.MemoryManager;
034import jmri.NamedBean;
035import jmri.PowerManager;
036import jmri.ReporterManager;
037import jmri.RouteManager;
038import jmri.Sensor;
039import jmri.SensorManager;
040import jmri.ShutDownManager;
041import jmri.SignalHead;
042import jmri.SignalHeadManager;
043import jmri.SignalMastManager;
044import jmri.Turnout;
045import jmri.TurnoutManager;
046import jmri.jmrit.display.layoutEditor.LayoutBlockManager;
047import jmri.jmrit.logix.WarrantManager;
048import jmri.util.FileUtil;
049import jmri.util.FileUtilSupport;
050import org.apache.commons.io.FilenameUtils;
051import org.python.core.PySystemState;
052import org.python.util.PythonInterpreter;
053import org.slf4j.Logger;
054import org.slf4j.LoggerFactory;
055
056/**
057 * Provide a manager for {@link javax.script.ScriptEngine}s. The following
058 * methods are the only mechanisms for evaluating a Python script that respect
059 * the <code>jython.exec</code> property in the <em>python.properties</em> file:
060 * <ul>
061 * <li>{@link #eval(java.io.File)}</li>
062 * <li>{@link #eval(java.io.File, javax.script.Bindings)}</li>
063 * <li>{@link #eval(java.io.File, javax.script.ScriptContext)}</li>
064 * <li>{@link #eval(java.lang.String, javax.script.ScriptEngine)}</li>
065 * <li>{@link #runScript(java.io.File)}</li>
066 * </ul>
067 * Evaluating a script using <code>getEngine*(java.lang.String).eval(...)</code>
068 * methods will not respect the <code>jython.exec</code> property, although all
069 * methods will respect all other properties of that file.
070 *
071 * @author Randall Wood
072 */
073public final class JmriScriptEngineManager implements InstanceManagerAutoDefault {
074
075    private final ScriptEngineManager manager = new ScriptEngineManager();
076    private final HashMap<String, String> names = new HashMap<>();
077    private final HashMap<String, ScriptEngineFactory> factories = new HashMap<>();
078    private final HashMap<String, ScriptEngine> engines = new HashMap<>();
079    private final ScriptContext context;
080
081    private static final Logger log = LoggerFactory.getLogger(JmriScriptEngineManager.class);
082    // should be replaced with default context
083    // package private for unit testing
084    static final String JYTHON_DEFAULTS = "jmri_defaults.py";
085    private static final String EXTENSION = "extension";
086    public static final String PYTHON = "jython";
087    private PythonInterpreter jython = null;
088
089    /**
090     * Create a JmriScriptEngineManager. In most cases, it is preferable to use
091     * {@link #getDefault()} to get existing {@link javax.script.ScriptEngine}
092     * instances.
093     */
094    public JmriScriptEngineManager() {
095        this.manager.getEngineFactories().stream().forEach(factory -> {
096            log.info("{} {} is provided by {} {}",
097                    factory.getLanguageName(),
098                    factory.getLanguageVersion(),
099                    factory.getEngineName(),
100                    factory.getEngineVersion());
101            String engineName = factory.getEngineName();
102            factory.getExtensions().stream().forEach(extension -> {
103                names.put(extension, engineName);
104                log.debug("\tExtension: {}", extension);
105            });
106            factory.getMimeTypes().stream().forEach(mimeType -> {
107                names.put(mimeType, engineName);
108                log.debug("\tMime type: {}", mimeType);
109            });
110            factory.getNames().stream().forEach(name -> {
111                names.put(name, engineName);
112                log.debug("\tNames: {}", name);
113            });
114            this.names.put(factory.getLanguageName(), engineName);
115            this.names.put(engineName, engineName);
116            this.factories.put(engineName, factory);
117        });
118
119        // this should agree with help/en/html/tools/scripting/Start.shtml
120        Bindings bindings = new SimpleBindings();
121        bindings.put("sensors", InstanceManager.getNullableDefault(SensorManager.class));
122        bindings.put("turnouts", InstanceManager.getNullableDefault(TurnoutManager.class));
123        bindings.put("lights", InstanceManager.getNullableDefault(LightManager.class));
124        bindings.put("signals", InstanceManager.getNullableDefault(SignalHeadManager.class));
125        bindings.put("masts", InstanceManager.getNullableDefault(SignalMastManager.class));
126        bindings.put("routes", InstanceManager.getNullableDefault(RouteManager.class));
127        bindings.put("blocks", InstanceManager.getNullableDefault(BlockManager.class));
128        bindings.put("reporters", InstanceManager.getNullableDefault(ReporterManager.class));
129        bindings.put("memories", InstanceManager.getNullableDefault(MemoryManager.class));
130        bindings.put("powermanager", InstanceManager.getNullableDefault(PowerManager.class));
131        bindings.put("addressedProgrammers", InstanceManager.getNullableDefault(AddressedProgrammerManager.class));
132        bindings.put("globalProgrammers", InstanceManager.getNullableDefault(GlobalProgrammerManager.class));
133        bindings.put("dcc", InstanceManager.getNullableDefault(CommandStation.class));
134        bindings.put("audio", InstanceManager.getNullableDefault(AudioManager.class));
135        bindings.put("shutdown", InstanceManager.getNullableDefault(ShutDownManager.class));
136        bindings.put("layoutblocks", InstanceManager.getNullableDefault(LayoutBlockManager.class));
137        bindings.put("warrants", InstanceManager.getNullableDefault(WarrantManager.class));
138        bindings.put("CLOSED", Turnout.CLOSED);
139        bindings.put("THROWN", Turnout.THROWN);
140        bindings.put("CABLOCKOUT", Turnout.CABLOCKOUT);
141        bindings.put("PUSHBUTTONLOCKOUT", Turnout.PUSHBUTTONLOCKOUT);
142        bindings.put("UNLOCKED", Turnout.UNLOCKED);
143        bindings.put("LOCKED", Turnout.LOCKED);
144        bindings.put("ACTIVE", Sensor.ACTIVE);
145        bindings.put("INACTIVE", Sensor.INACTIVE);
146        bindings.put("ON", Light.ON);
147        bindings.put("OFF", Light.OFF);
148        bindings.put("UNKNOWN", NamedBean.UNKNOWN);
149        bindings.put("INCONSISTENT", NamedBean.INCONSISTENT);
150        bindings.put("DARK", SignalHead.DARK);
151        bindings.put("RED", SignalHead.RED);
152        bindings.put("YELLOW", SignalHead.YELLOW);
153        bindings.put("GREEN", SignalHead.GREEN);
154        bindings.put("LUNAR", SignalHead.LUNAR);
155        bindings.put("FLASHRED", SignalHead.FLASHRED);
156        bindings.put("FLASHYELLOW", SignalHead.FLASHYELLOW);
157        bindings.put("FLASHGREEN", SignalHead.FLASHGREEN);
158        bindings.put("FLASHLUNAR", SignalHead.FLASHLUNAR);
159        bindings.put("FileUtil", FileUtilSupport.getDefault());
160        this.context = new SimpleScriptContext();
161        this.context.setBindings(bindings, ScriptContext.GLOBAL_SCOPE);
162    }
163
164    /**
165     * Get the default instance of a JmriScriptEngineManager. Using the default
166     * instance ensures that a script retains the context of the prior script.
167     *
168     * @return the default JmriScriptEngineManager
169     */
170    @Nonnull
171    public static JmriScriptEngineManager getDefault() {
172        return InstanceManager.getDefault(JmriScriptEngineManager.class);
173    }
174
175    /**
176     * Get the Java ScriptEngineManager that this object contains.
177     *
178     * @return the ScriptEngineManager
179     */
180    @Nonnull
181    public ScriptEngineManager getManager() {
182        return this.manager;
183    }
184
185    /**
186     * Given a file extension, get the ScriptEngine registered to handle that
187     * extension.
188     *
189     * @param extension a file extension
190     * @return a ScriptEngine or null
191     * @throws ScriptException if unable to get a matching ScriptEngine
192     */
193    @Nonnull
194    public ScriptEngine getEngineByExtension(String extension) throws ScriptException {
195        return getEngine(extension, EXTENSION);
196    }
197
198    /**
199     * Given a mime type, get the ScriptEngine registered to handle that mime
200     * type.
201     *
202     * @param mimeType a mimeType for a script
203     * @return a ScriptEngine or null
204     * @throws ScriptException if unable to get a matching ScriptEngine
205     */
206    @Nonnull
207    public ScriptEngine getEngineByMimeType(String mimeType) throws ScriptException {
208        return getEngine(mimeType, "mime type");
209    }
210
211    /**
212     * Given a short name, get the ScriptEngine registered by that name.
213     *
214     * @param shortName the short name for the ScriptEngine
215     * @return a ScriptEngine or null
216     * @throws ScriptException if unable to get a matching ScriptEngine
217     */
218    @Nonnull
219    public ScriptEngine getEngineByName(String shortName) throws ScriptException {
220        return getEngine(shortName, "name");
221    }
222
223    @Nonnull
224    private ScriptEngine getEngine(@CheckForNull String engineName, @Nonnull String type) throws ScriptException {
225        String name = names.get(engineName);
226        ScriptEngine engine = getEngine(name);
227        if (name == null || engine == null) {
228            throw scriptEngineNotFound(engineName, type, false);
229        }
230        return engine;
231    }
232
233    /**
234     * Get a ScriptEngine by its name(s), mime type, or supported extensions.
235     *
236     * @param name the complete name, mime type, or extension for the
237     *             ScriptEngine
238     * @return a ScriptEngine or null if matching engine not found
239     */
240    @CheckForNull
241    public ScriptEngine getEngine(@CheckForNull String name) {
242        if (!engines.containsKey(name)) {
243            name = names.get(name);
244            ScriptEngineFactory factory;
245            if (PYTHON.equals(name)) {
246                // Setup the default python engine to use the JMRI python
247                // properties
248                initializePython();
249            } else if ((factory = factories.get(name)) != null) {
250                log.debug("Create engine for {}", name);
251                ScriptEngine engine = factory.getScriptEngine();
252                engine.setContext(context);
253                engines.put(name, engine);
254            }
255        }
256        return engines.get(name);
257    }
258
259    /**
260     * Evaluate a script using the given ScriptEngine.
261     *
262     * @param script The script.
263     * @param engine The script engine.
264     * @return The results of evaluating the script.
265     * @throws javax.script.ScriptException if there is an error in the script.
266     */
267    public Object eval(String script, ScriptEngine engine) throws ScriptException {
268        if (PYTHON.equals(engine.getFactory().getEngineName()) && this.jython != null) {
269            this.jython.exec(script);
270            return null;
271        }
272        return engine.eval(script);
273    }
274
275    /**
276     * Evaluate a script using the given ScriptEngine.
277     *
278     * @param reader The script.
279     * @param engine The script engine.
280     * @return The results of evaluating the script.
281     * @throws javax.script.ScriptException if there is an error in the script.
282     * @deprecated since 4.17.5; use {@link ScriptEngine#eval(Reader)} instead
283     */
284    @Deprecated
285    public Object eval(Reader reader, ScriptEngine engine) throws ScriptException {
286        return engine.eval(reader);
287    }
288
289    /**
290     * Evaluate a script using the given ScriptEngine and Bindings.
291     *
292     * @param reader   The script.
293     * @param engine   The script engine.
294     * @param bindings Bindings passed to the script.
295     * @return The results of evaluating the script.
296     * @throws javax.script.ScriptException if there is an error in the script.
297     * @deprecated since 4.17.5; use {@link ScriptEngine#eval(Reader, Bindings)} instead
298     */
299    @Deprecated
300    public Object eval(Reader reader, ScriptEngine engine, Bindings bindings) throws ScriptException {
301        return engine.eval(reader, bindings);
302    }
303
304    /**
305     * Evaluate a script using the given ScriptEngine and Bindings.
306     *
307     * @param reader  The script.
308     * @param engine  The script engine.
309     * @param context Context for the script.
310     * @return The results of evaluating the script.
311     * @throws javax.script.ScriptException if there is an error in the script.
312     * @deprecated since 4.17.5; use {@link ScriptEngine#eval(Reader, ScriptContext)} instead
313     */
314    @Deprecated
315    public Object eval(Reader reader, ScriptEngine engine, ScriptContext context) throws ScriptException {
316        return engine.eval(reader, context);
317    }
318
319    /**
320     * Evaluate a script contained in a file. Uses the extension of the file to
321     * determine which ScriptEngine to use.
322     *
323     * @param file the script file to evaluate.
324     * @return the results of the evaluation.
325     * @throws javax.script.ScriptException  if there is an error evaluating the
326     *                                       script.
327     * @throws java.io.FileNotFoundException if the script file cannot be found.
328     * @throws java.io.IOException           if the script file cannot be read.
329     */
330    public Object eval(File file) throws ScriptException, IOException {
331        return eval(file, null, null);
332    }
333
334    /**
335     * Evaluate a script contained in a file given a set of
336     * {@link javax.script.Bindings} to add to the script's context. Uses the
337     * extension of the file to determine which ScriptEngine to use.
338     *
339     * @param file     the script file to evaluate.
340     * @param bindings script bindings to evaluate against.
341     * @return the results of the evaluation.
342     * @throws javax.script.ScriptException  if there is an error evaluating the
343     *                                       script.
344     * @throws java.io.FileNotFoundException if the script file cannot be found.
345     * @throws java.io.IOException           if the script file cannot be read.
346     */
347    public Object eval(File file, Bindings bindings) throws ScriptException, IOException {
348        return eval(file, null, bindings);
349    }
350
351    /**
352     * Evaluate a script contained in a file given a special context for the
353     * script. Uses the extension of the file to determine which ScriptEngine to
354     * use.
355     *
356     * @param file    the script file to evaluate.
357     * @param context script context to evaluate within.
358     * @return the results of the evaluation.
359     * @throws javax.script.ScriptException  if there is an error evaluating the
360     *                                       script.
361     * @throws java.io.FileNotFoundException if the script file cannot be found.
362     * @throws java.io.IOException           if the script file cannot be read.
363     */
364    public Object eval(File file, ScriptContext context) throws ScriptException, IOException {
365        return eval(file, context, null);
366    }
367
368    /**
369     * Evaluate a script contained in a file given a set of
370     * {@link javax.script.Bindings} to add to the script's context. Uses the
371     * extension of the file to determine which ScriptEngine to use.
372     *
373     * @param file     the script file to evaluate.
374     * @param context  script context to evaluate within.
375     * @param bindings script bindings to evaluate against.
376     * @return the results of the evaluation.
377     * @throws javax.script.ScriptException  if there is an error evaluating the
378     *                                       script.
379     * @throws java.io.FileNotFoundException if the script file cannot be found.
380     * @throws java.io.IOException           if the script file cannot be read.
381     */
382    @CheckForNull
383    private Object eval(File file, @CheckForNull ScriptContext context, @CheckForNull Bindings bindings)
384            throws ScriptException, IOException {
385        ScriptEngine engine;
386        Object result = null;
387        if ((engine = getEngineOrEval(file)) != null) {
388            try (InputStreamReader reader = new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8)) {
389                if (context != null) {
390                    result = engine.eval(reader, context);
391                } else if (bindings != null) {
392                    result = engine.eval(reader, bindings);
393                } else {
394                    result = engine.eval(reader);
395                }
396            }
397        }
398        return result;
399    }
400
401    /**
402     * Get the ScriptEngine to evaluate the file with; if not using a
403     * ScriptEngine to evaluate Python files, evaluate the file with a
404     * {@link org.python.util.PythonInterpreter} and do not return a
405     * ScriptEngine.
406     *
407     * @param file the script file to evaluate.
408     * @return the ScriptEngine or null if evaluated with a PythonInterpreter.
409     * @throws javax.script.ScriptException  if there is an error evaluating the
410     *                                       script.
411     * @throws java.io.FileNotFoundException if the script file cannot be found.
412     * @throws java.io.IOException           if the script file cannot be read.
413     */
414    @CheckForNull
415    private ScriptEngine getEngineOrEval(File file) throws ScriptException, IOException {
416        ScriptEngine engine = this.getEngine(FilenameUtils.getExtension(file.getName()), EXTENSION);
417        if (PYTHON.equals(engine.getFactory().getEngineName()) && this.jython != null) {
418            try (FileInputStream fi = new FileInputStream(file)) {
419                this.jython.execfile(fi);
420            }
421            return null;
422        }
423        return engine;
424    }
425
426    /**
427     * Run a script, suppressing common errors. Note that the file needs to have
428     * a registered extension, or a NullPointerException will be thrown.
429     * <p>
430     * <strong>Note:</strong> this will eventually be deprecated in favor of using
431     * {@link #eval(File)} and having callers handle exceptions.
432     *
433     * @param file the script to run.
434     */
435    public void runScript(File file) {
436        try {
437            this.eval(file);
438        } catch (FileNotFoundException ex) {
439            log.error("File {} not found.", file);
440        } catch (IOException ex) {
441            log.error("Exception working with file {}", file);
442        } catch (ScriptException ex) {
443            log.error("Error in script {}.", file, ex);
444        }
445
446    }
447
448    /**
449     * Initialize all ScriptEngines. This can be used to prevent the on-demand
450     * initialization of a ScriptEngine from causing a pause in JMRI.
451     */
452    public void initializeAllEngines() {
453        this.factories.keySet().stream().forEach(this::getEngine);
454    }
455
456    /**
457     * Get the default {@link javax.script.ScriptContext} for all
458     * {@link javax.script.ScriptEngine}s.
459     *
460     * @return the default ScriptContext;
461     */
462    @Nonnull
463    public ScriptContext getDefaultContext() {
464        return this.context;
465    }
466
467    /**
468     * Given a file extension, get the ScriptEngineFactory registered to handle
469     * that extension.
470     *
471     * @param extension a file extension
472     * @return a ScriptEngineFactory or null
473     * @throws ScriptException if unable to get a matching ScriptEngineFactory
474     */
475    @Nonnull
476    public ScriptEngineFactory getFactoryByExtension(String extension) throws ScriptException {
477        return getFactory(extension, EXTENSION);
478    }
479
480    /**
481     * Given a mime type, get the ScriptEngineFactory registered to handle that
482     * mime type.
483     *
484     * @param mimeType the script mimeType
485     * @return a ScriptEngineFactory or null
486     * @throws ScriptException if unable to get a matching ScriptEngineFactory
487     */
488    @Nonnull
489    public ScriptEngineFactory getFactoryByMimeType(String mimeType) throws ScriptException {
490        return getFactory(mimeType, "mime type");
491    }
492
493    /**
494     * Given a short name, get the ScriptEngineFactory registered by that name.
495     *
496     * @param shortName the short name for the factory
497     * @return a ScriptEngineFactory or null
498     * @throws ScriptException if unable to get a matching ScriptEngineFactory
499     */
500    @Nonnull
501    public ScriptEngineFactory getFactoryByName(String shortName) throws ScriptException {
502        return getFactory(shortName, "name");
503    }
504
505    @Nonnull
506    private ScriptEngineFactory getFactory(@CheckForNull String factoryName, @Nonnull String type)
507            throws ScriptException {
508        String name = this.names.get(factoryName);
509        ScriptEngineFactory factory = getFactory(name);
510        if (name == null || factory == null) {
511            throw scriptEngineNotFound(factoryName, type, true);
512        }
513        return factory;
514    }
515
516    /**
517     * Get a ScriptEngineFactory by its name(s), mime types, or supported
518     * extensions.
519     *
520     * @param name the complete name, mime type, or extension for a factory
521     * @return a ScriptEngineFactory or null
522     */
523    @CheckForNull
524    public ScriptEngineFactory getFactory(@CheckForNull String name) {
525        if (!factories.containsKey(name)) {
526            name = names.get(name);
527        }
528        return this.factories.get(name);
529    }
530
531    /**
532     * The Python ScriptEngine can be configured using a custom
533     * python.properties file and will run jmri_defaults.py if found in the
534     * user's configuration profile or settings directory. See python.properties
535     * in the JMRI installation directory for details of how to configure the
536     * Python ScriptEngine.
537     */
538    public void initializePython() {
539        if (!this.engines.containsKey(PYTHON)) {
540            initializePythonInterpreter(initializePythonState());
541        }
542    }
543
544    /**
545     * Create a new PythonInterpreter with the default bindings.
546     * 
547     * @return a new interpreter
548     */
549    public PythonInterpreter newPythonInterpreter() {
550        initializePython();
551        PythonInterpreter pi = new PythonInterpreter();
552        context.getBindings(ScriptContext.GLOBAL_SCOPE).forEach(pi::set);
553        return pi;
554    }
555
556    /**
557     * Initialize the Python ScriptEngine state including Python global state.
558     * 
559     * @return true if the Python interpreter will be used outside a
560     *         ScriptEngine; false otherwise
561     */
562    private boolean initializePythonState() {
563        // Get properties for interpreter
564        // Search in user files, the profile directory, the settings directory,
565        // and in the program path in that order
566        InputStream is = FileUtil.findInputStream("python.properties",
567                FileUtil.getUserFilesPath(),
568                FileUtil.getProfilePath(),
569                FileUtil.getPreferencesPath(),
570                FileUtil.getProgramPath());
571        Properties properties;
572        properties = new Properties(System.getProperties());
573        properties.setProperty("python.console.encoding", StandardCharsets.UTF_8.name()); // NOI18N
574        properties.setProperty("python.cachedir", FileUtil
575                .getAbsoluteFilename(properties.getProperty("python.cachedir", "settings:jython/cache"))); // NOI18N
576        boolean execJython = false;
577        if (is != null) {
578            String pythonPath = "python.path";
579            try {
580                properties.load(is);
581                String path = properties.getProperty(pythonPath, "");
582                if (path.length() != 0) {
583                    path = path.concat(File.pathSeparator);
584                }
585                properties.setProperty(pythonPath, path.concat(FileUtil.getScriptsPath()
586                        .concat(File.pathSeparator).concat(FileUtil.getAbsoluteFilename("program:jython"))));
587                execJython = Boolean.valueOf(properties.getProperty("jython.exec", Boolean.toString(execJython)));
588            } catch (IOException ex) {
589                log.error("Found, but unable to read python.properties: {}", ex.getMessage());
590            }
591            log.debug("Jython path is {}", PySystemState.getBaseProperties().getProperty(pythonPath));
592        }
593        PySystemState.initialize(null, properties);
594        return execJython;
595    }
596
597    /**
598     * Initialize the Python ScriptEngine and interpreter, including running any
599     * code in {@value #JYTHON_DEFAULTS}, if present.
600     * 
601     * @param execJython true if also initializing an independent interpreter;
602     *                   false otherwise
603     */
604    private void initializePythonInterpreter(boolean execJython) {
605        // Create the interpreter
606        try {
607            log.debug("create interpreter");
608            ScriptEngine python = this.manager.getEngineByName(PYTHON);
609            python.setContext(this.context);
610            engines.put(PYTHON, python);
611            InputStream is = FileUtil.findInputStream(JYTHON_DEFAULTS,
612                    FileUtil.getUserFilesPath(),
613                    FileUtil.getProfilePath(),
614                    FileUtil.getPreferencesPath());
615            if (execJython) {
616                jython = newPythonInterpreter();
617            }
618            if (is != null) {
619                python.eval(new InputStreamReader(is));
620                if (this.jython != null) {
621                    this.jython.execfile(is);
622                }
623            }
624        } catch (ScriptException e) {
625            log.error("Exception creating jython system objects", e);
626        }
627    }
628
629    // package private for unit testing
630    @CheckForNull
631    PythonInterpreter getPythonInterpreter() {
632        return jython;
633    }
634
635    /**
636     * Helper to handle logging and exceptions.
637     * 
638     * @param key       the item for which a ScriptEngine or ScriptEngineFactory
639     *                  was not found
640     * @param type      the type of key (name, mime type, extension)
641     * @param isFactory true for a not found ScriptEngineFactory, false for a
642     *                  not found ScriptEngine
643     */
644    private ScriptException scriptEngineNotFound(@CheckForNull String key, @Nonnull String type, boolean isFactory) {
645        String expected = String.join(",", names.keySet());
646        String factory = isFactory ? " factory" : "";
647        log.error("Could not find script engine{} for {} \"{}\", expected one of {}", factory, type, key, expected);
648        return new ScriptException(String.format("Could not find script engine%s for %s \"%s\" expected one of %s",
649                factory, type, key, expected));
650    }
651}