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