From cf43602c647619899b7ef3ad8622d8ac546a3a89 Mon Sep 17 00:00:00 2001 From: Alex Piechowski Date: Tue, 21 Apr 2026 09:07:34 +0700 Subject: [PATCH] Add Ruby scripting extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a JRuby 9.4.5.0 language extension as :extension:ruby, providing .rb script support. New ExtensionSpec(extId = "jruby") entry in stonecutter.gradle.kts wires it into the multi-version build. JRuby is embedded into the output jar under META-INF/jsmacroscedeps/. Port of grepsedawk/JsMacros-Ruby (an unofficial 1.21.8 patch of wagyourtail's original jsmacros-ruby), adapted to CE's API: split LanguageExtension / LibraryExtension interfaces, Core generic on language/context constructors, non-singleton profile access (ctx.runner.profile rather than Core.getInstance().profile). Script bindings are exposed as local variables context / event / file (no dollar prefix). This restores the pre-139c8ce wagyourtail convention — legacy scripts that reference bare "context" break under the post-139c8ce global-only form. Fixes applied atop the straight port: - Daemon flags + names on the preload thread and the JavaWrapper async thread (upstream spawned non-daemon threads that could prevent JVM shutdown). - Removed a dead Semaphore + unused local in FWrapper.inner_accept. - Extracted a shared callFn helper to deduplicate the three near-identical JRuby-call blocks in FWrapper.RubyMethodWrapper. Polish per PR review: - JRubyExtension.getLanguage: synchronized lazy init to remove the classloader-swap race between concurrent callers. - JRubyExtension.wrapException: null-guard StackTraceElement.getFileName (can be null for synthetic frames) and rewrite the recursive walker iteratively so deep JRuby backtraces cannot StackOverflowError. - JRubyLanguageDefinition: read script files with Files.newBufferedReader(UTF_8) inside try-with-resources instead of the platform-default, never-closed FileReader. - JRubyLanguageDefinition: parentPathOf helper null-guards both getFile() and getParentFile() so bare filenames do not NPE. - JRubyLanguageDefinition: move event/file/context bindings into runInstance; both exec overloads just hand in a scriptlet runner. - FWrapper.callFn: pop the pushed JRuby scope in a finally so the scope stack does not accumulate across callback invocations. - FWrapper.compare: convert the Ruby result via Number.intValue instead of an unchecked Object to int cast (JRuby returns Long). - FWrapper: drop the redundant await parameter threaded through innerAccept — it was always this.await. Camel-case inner_accept / inner_apply. - build.gradle.kts: fail fast with a clear GradleException when stonecutter.active is missing or empty, matching the root script. Intentionally dropped from upstream: - JRubyConfig / useGlobalContext option — never registered upstream (the addOptions call was commented out) and JRubyLanguageDefinition never reads the option. - assets/jsmacros/jruby/lang/en_us.json — its only key was for the unregistered config option. Build matrix: :extension:ruby:build is verified on stonecutter.active = 1.21.8. Extension has no direct MC-API dependencies, so the same compiled classes are reused across stonecutter versions and will inherit 26.1 compatibility automatically once that lands. Runtime verification: in-process ServiceLoader smoke test via jshell on JDK 21 discovers JRubyExtension, resolves getExtensionName -> "jruby", minCoreVersion=2.0.0, maxCoreVersion=2.1.0, getDependencies returns the embedded jruby-complete-9.4.5.0.jar URL, implements both LanguageExtension and LibraryExtension, and ScriptingContainer resolves from the embedded jar. Not smoke-tested inside MC's run environment. --- extension/ruby/build.gradle.kts | 82 ++++++++++ .../jsmacros/jruby/client/JRubyExtension.java | 128 +++++++++++++++ .../impl/JRubyLanguageDefinition.java | 81 ++++++++++ .../language/impl/JRubyScriptContext.java | 29 ++++ .../jsmacros/jruby/library/impl/FWrapper.java | 152 ++++++++++++++++++ ...acrosce.jsmacros.core.extensions.Extension | 1 + .../main/resources/jsmacrosce.ext.jruby.json | 3 + settings.gradle.kts | 1 + stonecutter.gradle.kts | 3 +- 9 files changed, 479 insertions(+), 1 deletion(-) create mode 100644 extension/ruby/build.gradle.kts create mode 100644 extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/client/JRubyExtension.java create mode 100644 extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/language/impl/JRubyLanguageDefinition.java create mode 100644 extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/language/impl/JRubyScriptContext.java create mode 100644 extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/library/impl/FWrapper.java create mode 100644 extension/ruby/src/main/resources/META-INF/services/com.jsmacrosce.jsmacros.core.extensions.Extension create mode 100644 extension/ruby/src/main/resources/jsmacrosce.ext.jruby.json diff --git a/extension/ruby/build.gradle.kts b/extension/ruby/build.gradle.kts new file mode 100644 index 000000000..854cadc51 --- /dev/null +++ b/extension/ruby/build.gradle.kts @@ -0,0 +1,82 @@ +import org.gradle.language.jvm.tasks.ProcessResources + +plugins { + `java-library` +} + +base { + archivesName.set("${property("mod_id")}-ruby") +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(property("java_version").toString().toInt())) + } + withSourcesJar() +} + +repositories { + mavenCentral() +} + +// Get minecraft version from stonecutter.active file +val minecraftVersion = rootProject.file("stonecutter.active").takeIf { it.exists() } + ?.readText()?.trim()?.ifEmpty { null } + ?: throw GradleException("stonecutter.active is empty; set an active version first") + +// Configuration for runtime dependencies to embed in the extension jar +val embedDeps by configurations.creating { + isCanBeResolved = true + isCanBeConsumed = false +} + +dependencies { + // Depends on extension module + implementation(project(":extension")) + + // Compile against shared common code + compileOnly(project(":common:${minecraftVersion}")) + compileOnly("org.jetbrains:annotations:20.1.0") + + // JRuby runtime - embedded into the extension jar so users don't need it on the classpath + implementation("org.jruby:jruby-complete:9.4.5.0") + add(embedDeps.name, "org.jruby:jruby-complete:9.4.5.0") + + // Common library dependencies, google deps must align with neoforged + implementation("com.google.guava:guava:31.1-jre") + implementation("com.google.code.gson:gson:2.10") + implementation("org.slf4j:slf4j-api:2.0.16") + + // Test dependencies + testImplementation(project(":extension")) + testImplementation(project(":common:${minecraftVersion}")) + testImplementation("org.junit.jupiter:junit-jupiter-api:5.8.1") + testImplementation("org.jetbrains:annotations:20.1.0") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.8.1") +} + +// Collect embedded dependency paths for the json file +fun getEmbeddedDepPaths(): String = + embedDeps.files.joinToString(", ") { file -> + "\"META-INF/jsmacroscedeps/${file.name}\"" + } + +// Process resources to expand dependencies placeholder +tasks.named("processResources") { + filesMatching("jsmacrosce.ext.jruby.json") { + expand(mapOf("dependencies" to getEmbeddedDepPaths())) + } +} + +// Embed dependencies into the extension jar +tasks.named("jar") { + dependsOn(embedDeps) + from(embedDeps) { + into("META-INF/jsmacroscedeps") + } + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} + +tasks.test { + useJUnitPlatform() +} diff --git a/extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/client/JRubyExtension.java b/extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/client/JRubyExtension.java new file mode 100644 index 000000000..c1bbe43e3 --- /dev/null +++ b/extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/client/JRubyExtension.java @@ -0,0 +1,128 @@ +package com.jsmacrosce.jsmacros.jruby.client; + +import com.google.common.collect.Sets; +import org.jruby.RubyException; +import org.jruby.embed.EvalFailedException; +import org.jruby.embed.ScriptingContainer; +import org.jruby.exceptions.RaiseException; +import org.jruby.runtime.backtrace.RubyStackTraceElement; +import org.jruby.runtime.builtin.IRubyObject; +import com.jsmacrosce.jsmacros.core.Core; +import com.jsmacrosce.jsmacros.core.extensions.LanguageExtension; +import com.jsmacrosce.jsmacros.core.extensions.LibraryExtension; +import com.jsmacrosce.jsmacros.core.language.BaseLanguage; +import com.jsmacrosce.jsmacros.core.language.BaseWrappedException; +import com.jsmacrosce.jsmacros.core.library.BaseLibrary; +import com.jsmacrosce.jsmacros.jruby.language.impl.JRubyLanguageDefinition; +import com.jsmacrosce.jsmacros.jruby.library.impl.FWrapper; + +import java.io.File; +import java.util.Arrays; +import java.util.Set; + +public class JRubyExtension implements LanguageExtension, LibraryExtension { + + private static JRubyLanguageDefinition languageDefinition; + + @Override + public String getExtensionName() { + return "jruby"; + } + + @Override + public void init(Core runner) { + Thread t = new Thread(() -> { + ScriptingContainer instance = new ScriptingContainer(); + instance.runScriptlet("p \"Ruby Pre-Loaded\""); + instance.terminate(); + }, "JRuby-Preload"); + t.setDaemon(true); + t.start(); + } + + @Override + public int getPriority() { + return 0; + } + + @Override + public ExtMatch extensionMatch(File file) { + if (file.getName().endsWith(".rb")) { + if (file.getName().contains(getExtensionName())) { + return ExtMatch.MATCH_WITH_NAME; + } else { + return ExtMatch.MATCH; + } + } + return ExtMatch.NOT_MATCH; + } + + @Override + public String defaultFileExtension() { + return "rb"; + } + + @Override + public synchronized BaseLanguage getLanguage(Core runner) { + if (languageDefinition == null) { + ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); + Thread.currentThread().setContextClassLoader(JRubyExtension.class.getClassLoader()); + try { + languageDefinition = new JRubyLanguageDefinition(this, runner); + } finally { + Thread.currentThread().setContextClassLoader(classLoader); + } + } + return languageDefinition; + } + + @Override + public Set> getLibraries() { + return Sets.newHashSet(FWrapper.class); + } + + @Override + public BaseWrappedException wrapException(Throwable ex) { + if (!(ex instanceof EvalFailedException)) return null; + Throwable cause = ex.getCause(); + if (cause instanceof RaiseException) { + RubyException e = ((RaiseException) cause).getException(); + StackTraceElement[] frames = Arrays.stream(e.getBacktraceElements()) + .map(RubyStackTraceElement::asStackTraceElement) + .toArray(StackTraceElement[]::new); + return new BaseWrappedException<>(e, e.getMessageAsJavaString(), null, buildTrace(frames)); + } + return new BaseWrappedException<>(cause, cause.getClass().getName() + ": " + cause.getMessage(), null, buildTrace(cause.getStackTrace())); + } + + private BaseWrappedException buildTrace(StackTraceElement[] frames) { + BaseWrappedException head = null; + for (int i = frames.length - 1; i >= 0; i--) { + StackTraceElement frame = frames[i]; + String cls = frame.getClassName(); + if ("org.jruby.embed.internal.EmbedEvalUnitImpl".equals(cls)) { + // upstream ran here — discard everything we've accumulated above it in the chain + head = null; + continue; + } + if (cls.startsWith("org.jruby")) continue; + BaseWrappedException.SourceLocation loc; + if ("RUBY".equals(cls)) { + String fileName = frame.getFileName(); + loc = new BaseWrappedException.GuestLocation( + fileName != null ? new File(fileName) : null, + -1, -1, frame.getLineNumber(), -1); + } else { + loc = new BaseWrappedException.HostLocation(cls + " " + frame.getLineNumber()); + } + head = new BaseWrappedException<>(frame, frame.getMethodName(), loc, head); + } + return head; + } + + @Override + public boolean isGuestObject(Object o) { + return o instanceof IRubyObject; + } + +} diff --git a/extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/language/impl/JRubyLanguageDefinition.java b/extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/language/impl/JRubyLanguageDefinition.java new file mode 100644 index 000000000..280a8dc5e --- /dev/null +++ b/extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/language/impl/JRubyLanguageDefinition.java @@ -0,0 +1,81 @@ +package com.jsmacrosce.jsmacros.jruby.language.impl; + +import org.jruby.embed.LocalContextScope; +import org.jruby.embed.ScriptingContainer; +import com.jsmacrosce.jsmacros.core.Core; +import com.jsmacrosce.jsmacros.core.config.ScriptTrigger; +import com.jsmacrosce.jsmacros.core.event.BaseEvent; +import com.jsmacrosce.jsmacros.core.language.BaseLanguage; +import com.jsmacrosce.jsmacros.core.language.EventContainer; +import com.jsmacrosce.jsmacros.jruby.client.JRubyExtension; + +import org.jetbrains.annotations.Nullable; +import java.io.File; +import java.io.Reader; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +public class JRubyLanguageDefinition extends BaseLanguage { + public JRubyLanguageDefinition(JRubyExtension extension, Core runner) { + super(extension, runner); + } + + private void runInstance(EventContainer ctx, BaseEvent event, ScriptletRunner scriptlet, @Nullable Path cwd) throws Exception { + ScriptingContainer instance = new ScriptingContainer(LocalContextScope.SINGLETHREAD); + ctx.getCtx().setContext(instance); + + if (cwd != null) { + instance.setCurrentDirectory(cwd.toString()); + } + + retrieveLibs(ctx.getCtx()).forEach((name, lib) -> { + // "Time" is a built-in Ruby class; expose jsmacros' Time library under FTime instead. + String bindName = "Time".equals(name) ? "FTime" : name; + instance.put(bindName, lib); + }); + instance.put("event", event); + instance.put("file", ctx.getCtx().getFile()); + instance.put("context", ctx); + + scriptlet.run(instance); + } + + @Override + protected void exec(EventContainer ctx, ScriptTrigger macro, BaseEvent event) throws Exception { + File file = ctx.getCtx().getFile(); + runInstance(ctx, event, instance -> { + try (Reader reader = Files.newBufferedReader(file.toPath(), StandardCharsets.UTF_8)) { + instance.runScriptlet(reader, file.getAbsolutePath()); + } + }, parentPathOf(file)); + } + + @Override + protected void exec(EventContainer ctx, String lang, String script, BaseEvent event) throws Exception { + File file = ctx.getCtx().getFile(); + runInstance(ctx, event, instance -> { + if (file != null) { + instance.runScriptlet(new StringReader(script), file.getAbsolutePath()); + } else { + instance.runScriptlet(script); + } + }, parentPathOf(file)); + } + + @Override + public JRubyScriptContext createContext(BaseEvent event, File path) { + return new JRubyScriptContext(runner, event, path); + } + + private static @Nullable Path parentPathOf(@Nullable File f) { + if (f == null) return null; + File parent = f.getParentFile(); + return parent != null ? parent.toPath() : null; + } + + private interface ScriptletRunner { + void run(ScriptingContainer instance) throws Exception; + } +} diff --git a/extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/language/impl/JRubyScriptContext.java b/extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/language/impl/JRubyScriptContext.java new file mode 100644 index 000000000..ea68de93b --- /dev/null +++ b/extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/language/impl/JRubyScriptContext.java @@ -0,0 +1,29 @@ +package com.jsmacrosce.jsmacros.jruby.language.impl; + +import org.jruby.embed.ScriptingContainer; +import com.jsmacrosce.jsmacros.core.Core; +import com.jsmacrosce.jsmacros.core.event.BaseEvent; +import com.jsmacrosce.jsmacros.core.language.BaseScriptContext; + +import java.io.File; + +public class JRubyScriptContext extends BaseScriptContext { + public JRubyScriptContext(Core runner, BaseEvent event, File file) { + super(runner, event, file); + } + + @Override + public synchronized void closeContext() { + super.closeContext(); + ScriptingContainer ctx = getContext(); + if (ctx != null) { + ctx.terminate(); + } + } + + @Override + public boolean isMultiThreaded() { + return true; + } + +} diff --git a/extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/library/impl/FWrapper.java b/extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/library/impl/FWrapper.java new file mode 100644 index 000000000..abdd03368 --- /dev/null +++ b/extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/library/impl/FWrapper.java @@ -0,0 +1,152 @@ +package com.jsmacrosce.jsmacros.jruby.library.impl; + +import org.jruby.RubyMethod; +import org.jruby.embed.ScriptingContainer; +import org.jruby.javasupport.JavaUtil; +import org.jruby.runtime.ThreadContext; +import org.jruby.runtime.builtin.IRubyObject; +import com.jsmacrosce.jsmacros.core.MethodWrapper; +import com.jsmacrosce.jsmacros.core.language.BaseLanguage; +import com.jsmacrosce.jsmacros.core.library.IFWrapper; +import com.jsmacrosce.jsmacros.core.library.Library; +import com.jsmacrosce.jsmacros.core.library.PerExecLanguageLibrary; +import com.jsmacrosce.jsmacros.jruby.language.impl.JRubyLanguageDefinition; +import com.jsmacrosce.jsmacros.jruby.language.impl.JRubyScriptContext; + +@Library(value = "JavaWrapper", languages = JRubyLanguageDefinition.class) +public class FWrapper extends PerExecLanguageLibrary implements IFWrapper { + + public FWrapper(JRubyScriptContext context, Class> language) { + super(context, language); + } + + @Override + public MethodWrapper methodToJava(RubyMethod c) { + return new RubyMethodWrapper<>(c, true, ctx); + } + + @Override + public MethodWrapper methodToJavaAsync(RubyMethod c) { + return new RubyMethodWrapper<>(c, false, ctx); + } + + @Override + public void stop() { + ctx.closeContext(); + } + + private static class RubyMethodWrapper extends MethodWrapper { + private final RubyMethod fn; + private final boolean await; + + RubyMethodWrapper(RubyMethod fn, boolean await, JRubyScriptContext ctx) { + super(ctx); + this.fn = fn; + this.await = await; + } + + private Object callFn(Object... params) { + ThreadContext threadContext = ctx.getContext().getProvider().getRuntime().getCurrentContext(); + threadContext.pushNewScope(threadContext.getCurrentStaticScope()); + try { + IRubyObject[] rubyObjects = JavaUtil.convertJavaArrayToRuby(threadContext.runtime, params); + return fn.call(threadContext, rubyObjects, threadContext.getFrameBlock()).toJava(Object.class); + } finally { + threadContext.popScope(); + } + } + + private void innerAccept(Object... params) { + if (await) { + innerApply(params); + return; + } + + Thread t = new Thread(() -> { + ctx.bindThread(Thread.currentThread()); + try { + callFn(params); + } catch (Throwable ex) { + ctx.runner.profile.logError(ex); + } finally { + ctx.unbindThread(Thread.currentThread()); + ctx.runner.profile.joinedThreadStack.remove(Thread.currentThread()); + ctx.releaseBoundEventIfPresent(Thread.currentThread()); + } + }, "JRuby-JavaWrapper"); + t.setDaemon(true); + t.start(); + } + + @SuppressWarnings("unchecked") + private R2 innerApply(Object... params) { + if (ctx.getBoundThreads().contains(Thread.currentThread())) { + return (R2) callFn(params); + } + + try { + ctx.bindThread(Thread.currentThread()); + if (ctx.runner.profile.checkJoinedThreadStack()) { + ctx.runner.profile.joinedThreadStack.add(Thread.currentThread()); + } + return (R2) callFn(params); + } catch (Throwable ex) { + throw new RuntimeException(ex); + } finally { + ctx.releaseBoundEventIfPresent(Thread.currentThread()); + ctx.unbindThread(Thread.currentThread()); + ctx.runner.profile.joinedThreadStack.remove(Thread.currentThread()); + } + } + + @Override + public void accept(T t) { + innerAccept(t); + } + + @Override + public void accept(T t, U u) { + innerAccept(t, u); + } + + @Override + public R apply(T t) { + return innerApply(t); + } + + @Override + public R apply(T t, U u) { + return innerApply(t, u); + } + + @Override + public boolean test(T t) { + return (boolean) innerApply(t); + } + + @Override + public boolean test(T t, U u) { + return (boolean) innerApply(t, u); + } + + @Override + public void run() { + innerAccept(); + } + + @Override + public int compare(T o1, T o2) { + Object result = innerApply(o1, o2); + if (!(result instanceof Number)) { + throw new ClassCastException("Ruby comparator must return a numeric value, got: " + result); + } + return ((Number) result).intValue(); + } + + @Override + public R get() { + return innerApply(); + } + } + +} diff --git a/extension/ruby/src/main/resources/META-INF/services/com.jsmacrosce.jsmacros.core.extensions.Extension b/extension/ruby/src/main/resources/META-INF/services/com.jsmacrosce.jsmacros.core.extensions.Extension new file mode 100644 index 000000000..10f435a8d --- /dev/null +++ b/extension/ruby/src/main/resources/META-INF/services/com.jsmacrosce.jsmacros.core.extensions.Extension @@ -0,0 +1 @@ +com.jsmacrosce.jsmacros.jruby.client.JRubyExtension diff --git a/extension/ruby/src/main/resources/jsmacrosce.ext.jruby.json b/extension/ruby/src/main/resources/jsmacrosce.ext.jruby.json new file mode 100644 index 000000000..1f3aca682 --- /dev/null +++ b/extension/ruby/src/main/resources/jsmacrosce.ext.jruby.json @@ -0,0 +1,3 @@ +{ + "dependencies": [${dependencies}] +} diff --git a/settings.gradle.kts b/settings.gradle.kts index deb5bf1b2..9fee6482a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -65,6 +65,7 @@ include("extension") include("extension:graal") include("extension:graal:js") include("extension:graal:python") +include("extension:ruby") stonecutter { kotlinController = true diff --git a/stonecutter.gradle.kts b/stonecutter.gradle.kts index e08c3b576..4e609df1d 100644 --- a/stonecutter.gradle.kts +++ b/stonecutter.gradle.kts @@ -145,7 +145,8 @@ val loaders = listOf("fabric", "neoforge") data class ExtensionSpec(val path: String, val extId: String) val jsmExtensions: List = listOf( - ExtensionSpec(path = ":extension:graal:python", extId = "graalpy") + ExtensionSpec(path = ":extension:graal:python", extId = "graalpy"), + ExtensionSpec(path = ":extension:ruby", extId = "jruby") ) val artifactBaseName = providers.provider { "$modId-$mcVersion-$channel-$version" }