2 * Copyright (c) 2010-2024 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.automation.jrubyscripting.internal;
16 import java.nio.file.Paths;
17 import java.util.List;
19 import java.util.Objects;
20 import java.util.Optional;
21 import java.util.stream.Stream;
23 import javax.script.ScriptContext;
24 import javax.script.ScriptEngine;
25 import javax.script.ScriptEngineFactory;
26 import javax.script.ScriptException;
28 import org.eclipse.jdt.annotation.NonNullByDefault;
29 import org.eclipse.jdt.annotation.Nullable;
30 import org.jruby.runtime.Constants;
31 import org.openhab.core.OpenHAB;
32 import org.slf4j.Logger;
33 import org.slf4j.LoggerFactory;
36 * Processes JRuby Configuration Parameters.
38 * @author Brian O'Connell - Initial contribution
39 * @author Jimmy Tanagra - Add $LOAD_PATH, require injection
42 public class JRubyScriptEngineConfiguration {
44 private final Logger logger = LoggerFactory.getLogger(JRubyScriptEngineConfiguration.class);
46 private static final String RUBY_ENGINE_REPLACEMENT = "{RUBY_ENGINE}";
47 private static final String RUBY_ENGINE_VERSION_REPLACEMENT = "{RUBY_ENGINE_VERSION}";
48 private static final String RUBY_VERSION_REPLACEMENT = "{RUBY_VERSION}";
49 private static final List<String> REPLACEMENTS = List.of(RUBY_ENGINE_REPLACEMENT, RUBY_ENGINE_VERSION_REPLACEMENT,
50 RUBY_VERSION_REPLACEMENT);
52 private static final String DEFAULT_GEM_HOME = Paths
53 .get(OpenHAB.getConfigFolder(), "automation", "ruby", ".gem", RUBY_ENGINE_VERSION_REPLACEMENT).toString();
54 private static final String DEFAULT_RUBYLIB = Paths.get(OpenHAB.getConfigFolder(), "automation", "ruby", "lib")
57 private static final String GEM_HOME_CONFIG_KEY = "gem_home";
58 private static final String RUBYLIB_CONFIG_KEY = "rubylib";
59 private static final String GEMS_CONFIG_KEY = "gems";
60 private static final String REQUIRE_CONFIG_KEY = "require";
61 private static final String CHECK_UPDATE_CONFIG_KEY = "check_update";
62 private static final String DEPENDENCY_TRACKING_CONFIG_KEY = "dependency_tracking";
64 // Map of configuration parameters
65 private final Map<String, OptionalConfigurationElement> configurationParameters = Map.ofEntries(
66 Map.entry("local_context",
67 new OptionalConfigurationElement(OptionalConfigurationElement.Type.SYSTEM_PROPERTY, "singlethread",
68 "org.jruby.embed.localcontext.scope")),
70 Map.entry("local_variable",
71 new OptionalConfigurationElement(OptionalConfigurationElement.Type.SYSTEM_PROPERTY, "transient",
72 "org.jruby.embed.localvariable.behavior")),
74 Map.entry(GEM_HOME_CONFIG_KEY,
75 new OptionalConfigurationElement(OptionalConfigurationElement.Type.RUBY_ENVIRONMENT,
76 DEFAULT_GEM_HOME, "GEM_HOME")),
78 Map.entry(RUBYLIB_CONFIG_KEY,
79 new OptionalConfigurationElement(OptionalConfigurationElement.Type.RUBY_ENVIRONMENT,
80 DEFAULT_RUBYLIB, "RUBYLIB")),
82 Map.entry(GEMS_CONFIG_KEY, new OptionalConfigurationElement("openhab-scripting=~>5.0")),
84 Map.entry(REQUIRE_CONFIG_KEY, new OptionalConfigurationElement("openhab/dsl")),
86 Map.entry(CHECK_UPDATE_CONFIG_KEY, new OptionalConfigurationElement("true")),
88 Map.entry(DEPENDENCY_TRACKING_CONFIG_KEY, new OptionalConfigurationElement("true")));
91 * Update configuration
93 * @param config Configuration parameters to apply to ScriptEngine
94 * @param factory ScriptEngineFactory to configure
96 void update(Map<String, Object> config, ScriptEngineFactory factory) {
97 logger.trace("JRuby Script Engine Configuration: {}", config);
98 configurationParameters.forEach((k, v) -> v.clearValue());
99 config.forEach(this::processConfigValue);
101 configureSystemProperties();
103 ScriptEngine engine = factory.getScriptEngine();
104 configureRubyEnvironment(engine);
105 configureGems(engine);
109 * Apply configuration key/value to known configuration parameters
111 * @param key Configuration key
112 * @param value Configuration value
114 private void processConfigValue(String key, Object value) {
115 OptionalConfigurationElement configurationElement = configurationParameters.get(key);
116 if (configurationElement != null) {
117 configurationElement.setValue(value.toString().trim());
119 logger.debug("Ignoring unexpected configuration key: {}", key);
124 * Gets a single configuration element.
126 private String get(String key) {
127 OptionalConfigurationElement configElement = configurationParameters.get(key);
129 return Objects.requireNonNull(configElement).getValue();
133 * Gets the concrete gem home to install gems into for this version of JRuby.
135 * {RUBY_ENGINE} and {RUBY_VERSION} are replaced with their current actual values.
137 public String getSpecificGemHome() {
138 String gemHome = get(GEM_HOME_CONFIG_KEY);
139 if (gemHome.isEmpty()) {
143 gemHome = gemHome.replace(RUBY_ENGINE_REPLACEMENT, Constants.ENGINE);
144 gemHome = gemHome.replace(RUBY_ENGINE_VERSION_REPLACEMENT, Constants.VERSION);
145 gemHome = gemHome.replace(RUBY_VERSION_REPLACEMENT, Constants.RUBY_VERSION);
146 return new File(gemHome).toString();
150 * Get the base for all possible gem homes.
152 * If the configured gem home contains {RUBY_ENGINE} or {RUBY_VERSION},
153 * the path is cut off at that point. This means a single configuration
154 * value will include the gem homes for all parallel-installed ruby
158 public String getGemHomeBase() {
159 String gemHome = get(GEM_HOME_CONFIG_KEY);
161 for (String replacement : REPLACEMENTS) {
162 int loc = gemHome.indexOf(replacement);
164 gemHome = gemHome.substring(0, loc);
167 return new File(gemHome).toString();
171 * Makes Gem home directory if it does not exist
173 private boolean ensureGemHomeExists(String gemHome) {
174 File gemHomeDirectory = new File(gemHome);
175 if (!gemHomeDirectory.exists()) {
176 logger.debug("gem_home directory does not exist, creating");
177 if (!gemHomeDirectory.mkdirs()) {
178 logger.warn("Error creating gem_home directory");
186 * Install a gems in ScriptEngine
188 * @param engine Engine to install gems
190 private synchronized void configureGems(ScriptEngine engine) {
191 String gems = get(GEMS_CONFIG_KEY);
192 if (gems.isEmpty()) {
196 String gemHome = getSpecificGemHome();
197 if (gemHome.isEmpty()) {
198 logger.warn("Gem install requested with empty gem_home, not installing gems.");
202 if (!ensureGemHomeExists(gemHome)) {
206 boolean checkUpdate = "true".equals(get(CHECK_UPDATE_CONFIG_KEY));
208 String[] gemsArray = gems.split(",");
209 // Set update_native_env_enabled to false so that bundler doesn't leak
210 // into other script engines
211 String gemCommand = "require 'jruby'\nJRuby.runtime.instance_config.update_native_env_enabled = false\nrequire 'bundler/inline'\nrequire 'openssl'\n\ngemfile("
212 + checkUpdate + ") do\n" + " source 'https://rubygems.org/'\n";
214 for (String gem : gemsArray) {
216 String[] versions = {};
217 if (gem.contains("=")) {
218 String[] gemParts = gem.split("=", 2);
219 gem = gemParts[0].trim();
220 versions = gemParts[1].split(";");
227 gemCommand += " gem '" + gem + "'";
228 for (String version : versions) {
229 version = version.trim();
230 if (!version.isEmpty()) {
231 gemCommand += ", '" + version + "'";
234 gemCommand += ", require: false\n";
237 if (validGems == 0) {
240 gemCommand += "end\n";
243 logger.debug("Installing Gems");
244 logger.trace("Gem install code:\n{}", gemCommand);
245 engine.eval(gemCommand);
246 } catch (ScriptException e) {
247 logger.warn("Error installing Gems", unwrap(e));
252 * Execute ruby require statement in the ScriptEngine
254 * @param engine Engine to insert the require statements
256 public void injectRequire(ScriptEngine engine) {
257 String requires = get(REQUIRE_CONFIG_KEY);
259 if (requires.isEmpty()) {
263 Stream.of(requires.split(",")).map(s -> s.trim()).filter(s -> !s.isEmpty()).forEach(script -> {
264 final String requireStatement = String.format("require '%s'", script);
266 logger.trace("Injecting require statement: {}", requireStatement);
267 engine.eval(requireStatement);
268 } catch (ScriptException e) {
269 logger.warn("Error evaluating `{}`", requireStatement, unwrap(e));
275 * Configure the optional elements of the Ruby Environment
277 * @param scriptEngine Engine in which to configure environment
279 public void configureRubyEnvironment(ScriptEngine scriptEngine) {
280 getConfigurationElements(OptionalConfigurationElement.Type.RUBY_ENVIRONMENT).forEach(configElement -> {
282 if ("GEM_HOME".equals(configElement.mappedTo().get())) {
283 // this value has to be post-processed to handle replacements.
284 value = getSpecificGemHome();
286 value = configElement.getValue();
288 scriptEngine.put("__key", configElement.mappedTo().get());
289 scriptEngine.put("__value", value);
290 logger.trace("Setting Ruby environment ENV['{}''] = '{}'", configElement.mappedTo().get(), value);
293 scriptEngine.eval("ENV[__key] = __value");
294 } catch (ScriptException e) {
295 logger.warn("Error setting Ruby environment", unwrap(e));
297 // clean up our temporary variables
298 scriptEngine.getBindings(ScriptContext.ENGINE_SCOPE).remove("__key");
299 scriptEngine.getBindings(ScriptContext.ENGINE_SCOPE).remove("__value");
302 configureRubyLib(scriptEngine);
306 * Split up and insert ENV['RUBYLIB'] into Ruby's $LOAD_PATH
307 * This needs to be called after ENV['RUBYLIB'] has been set by configureRubyEnvironment
309 * @param engine Engine in which to configure environment
311 private void configureRubyLib(ScriptEngine engine) {
312 String rubyLib = get(RUBYLIB_CONFIG_KEY);
313 if (!rubyLib.isEmpty()) {
314 final String code = "$LOAD_PATH.unshift *ENV['RUBYLIB']&.split(File::PATH_SEPARATOR)" + //
315 "&.reject(&:empty?)" + //
316 "&.reject { |path| $LOAD_PATH.include?(path) }"; //
319 } catch (ScriptException exception) {
320 logger.warn("Error setting $LOAD_PATH from RUBYLIB='{}'", rubyLib, unwrap(exception));
325 public List<String> getRubyLibPaths() {
326 String rubyLib = get(RUBYLIB_CONFIG_KEY);
327 if (rubyLib.isEmpty()) {
330 return List.of(rubyLib.split(File.pathSeparator));
333 public boolean enableDependencyTracking() {
334 return "true".equals(get(DEPENDENCY_TRACKING_CONFIG_KEY));
338 * Configure system properties
340 * @param optionalConfigurationElements Optional system properties to configure
342 private void configureSystemProperties() {
343 getConfigurationElements(OptionalConfigurationElement.Type.SYSTEM_PROPERTY).forEach(configElement -> {
344 String systemProperty = configElement.mappedTo().get();
345 String propertyValue = configElement.getValue();
346 logger.trace("Setting system property ({}) to ({})", systemProperty, propertyValue);
347 System.setProperty(systemProperty, propertyValue);
351 private Stream<OptionalConfigurationElement> getConfigurationElements(OptionalConfigurationElement.Type type) {
352 return configurationParameters.values().stream().filter(element -> element.type.equals(type));
356 * Unwraps the cause of an exception, if it has one.
358 * Since a user cares about the _Ruby_ stack trace of the throwable, not
359 * the details of where openHAB called it.
361 private Throwable unwrap(Throwable e) {
362 Throwable cause = e.getCause();
370 * Inner static companion class for configuration elements
372 private static class OptionalConfigurationElement {
379 private final String defaultValue;
380 private final Optional<String> mappedTo;
381 private final Type type;
382 private Optional<String> value;
384 private OptionalConfigurationElement(String defaultValue) {
385 this(Type.OTHER, defaultValue, null);
388 private OptionalConfigurationElement(Type type, String defaultValue, @Nullable String mappedTo) {
390 this.defaultValue = defaultValue;
391 this.mappedTo = Optional.ofNullable(mappedTo);
392 value = Optional.empty();
395 private String getValue() {
396 return value.orElse(defaultValue);
399 private void setValue(String value) {
400 this.value = Optional.of(value);
403 private void clearValue() {
404 this.value = Optional.empty();
407 private Optional<String> mappedTo() {