Skip to content

Add Ruby scripting extension#21

Open
grepsedawk wants to merge 1 commit intoJsMacrosCE:mainfrom
grepsedawk:feat/ruby-extension
Open

Add Ruby scripting extension#21
grepsedawk wants to merge 1 commit intoJsMacrosCE:mainfrom
grepsedawk:feat/ruby-extension

Conversation

@grepsedawk
Copy link
Copy Markdown

Summary

Adds a JRuby 9.4.5 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).

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 — the semaphore was released but never acquired.
  • Extracted a shared callFn helper to deduplicate the three near-identical JRuby-call blocks in FWrapper.RubyMethodWrapper.

Script bindings

Bindings exposed as local variables context / event / file (no $ prefix). This restores the pre-139c8ce wagyourtail convention. Legacy scripts that reference bare context break under the post-139c8ce global-only form — this restores compatibility.

Vestigial bits dropped from upstream

  • JRubyConfig / useGlobalContext option — was never registered (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

Built :extension:ruby:jar against each stonecutter version:

  • 1.21.5 ✓
  • 1.21.8 ✓
  • 1.21.10 ✓
  • 1.21.11 ✓

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.

Testing

In-process ServiceLoader smoke test via jshell on JDK 21:

  • ServiceLoader.load(Extension.class, <classloader>) discovers JRubyExtension
  • getExtensionName()"jruby", minCoreVersion=2.0.0, maxCoreVersion=2.1.0
  • getDependencies() returns the embedded META-INF/jsmacroscedeps/jruby-complete-9.4.5.0.jar URL
  • Implements both LanguageExtension and LibraryExtension
  • Embedded jruby-complete jar (~33.5 MB) extracts; org.jruby.embed.ScriptingContainer resolves from it

Not smoke-tested inside MC's run environment.

Test plan

  • Drop the built jar into a dev client, confirm the mod loads without an extension registration error
  • Run a minimal .rb script via the mod UI and verify it executes
  • Confirm a script using JavaWrapper.methodToJava can register a Ruby callback that the host invokes
  • Confirm error-wrapping: deliberately raise inside a Ruby script and check the wrapped stack trace surfaces in logs

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 21, 2026

Warning

Rate limit exceeded

@grepsedawk has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 39 minutes and 3 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 39 minutes and 3 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 42365984-dc47-48c7-adf6-12d02b18f7dd

📥 Commits

Reviewing files that changed from the base of the PR and between d823ffa and cf43602.

📒 Files selected for processing (9)
  • extension/ruby/build.gradle.kts
  • extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/client/JRubyExtension.java
  • extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/language/impl/JRubyLanguageDefinition.java
  • extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/language/impl/JRubyScriptContext.java
  • extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/library/impl/FWrapper.java
  • extension/ruby/src/main/resources/META-INF/services/com.jsmacrosce.jsmacros.core.extensions.Extension
  • extension/ruby/src/main/resources/jsmacrosce.ext.jruby.json
  • settings.gradle.kts
  • stonecutter.gradle.kts
📝 Walkthrough

Walkthrough

A new JRuby language extension module is added to the project, enabling Ruby script execution support. The implementation includes build configuration with embedded dependency management, a core extension class, language definition with JRuby ScriptingContainer lifecycle management, script context handling, and a library wrapper for converting Ruby methods to Java callables with both synchronous and asynchronous execution paths.

Changes

Cohort / File(s) Summary
Build Configuration
extension/ruby/build.gradle.kts, settings.gradle.kts, stonecutter.gradle.kts
Added new Gradle module for JRuby extension with embedded dependency management, SPI service configuration, and resource placeholder expansion. Registered module in build settings and extension specs.
Extension Foundation
extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/client/JRubyExtension.java
Main extension class implementing LanguageExtension and LibraryExtension. Handles .rb file matching, daemon thread initialization with JRuby preload, lazy language definition construction, exception wrapping with backtrace extraction, and guest object identification.
Language Execution
extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/language/impl/JRubyLanguageDefinition.java
Language definition extending BaseLanguage<ScriptingContainer, JRubyScriptContext>. Manages ScriptingContainer lifecycle, library injection with name remapping, and dual execution paths for file-based and inline scripts.
Script Context Management
extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/language/impl/JRubyScriptContext.java
Script context extending BaseScriptContext<ScriptingContainer>. Handles synchronized container termination and reports multi-threaded execution capability.
Ruby Method Wrapping
extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/library/impl/FWrapper.java
Library wrapper registered as JavaWrapper for converting RubyMethod to MethodWrapper. Supports both synchronous (await-based) and asynchronous (daemon thread) execution with JRuby scope/thread binding and Java-Ruby object conversion.
Service & Resources
extension/ruby/src/main/resources/META-INF/services/com.jsmacrosce.jsmacros.core.extensions.Extension, extension/ruby/src/main/resources/jsmacrosce.ext.jruby.json
Added SPI service provider registration for runtime extension discovery and JSON resource file with placeholder-substituted dependency manifest.

Sequence Diagram

sequenceDiagram
    participant Client as Client
    participant JRubyExt as JRubyExtension
    participant JRubyLang as JRubyLanguageDefinition
    participant Container as ScriptingContainer
    participant FWrapper as FWrapper
    participant RubyRT as JRuby Runtime

    Client->>JRubyExt: init()
    JRubyExt->>JRubyExt: Start daemon thread
    JRubyExt->>Container: Create ScriptingContainer
    Container->>RubyRT: Initialize
    JRubyExt->>Container: runScriptlet("p 'Ruby Pre-Loaded'")
    Container->>RubyRT: Execute preload
    JRubyExt->>Container: terminate()

    Client->>JRubyExt: getLanguage()
    JRubyExt->>JRubyLang: Construct (swap ClassLoader)
    JRubyLang->>Container: Create ScriptingContainer
    JRubyLang->>Container: setCurrentDirectory(cwd)
    JRubyLang->>FWrapper: retrieveLibs()
    FWrapper->>Container: Inject libraries

    Client->>JRubyLang: exec(script)
    JRubyLang->>JRubyLang: runInstance()
    JRubyLang->>Container: runScriptlet(script, filename)
    Container->>RubyRT: Execute Ruby code
    RubyRT-->>Container: Return result

    Client->>FWrapper: methodToJava(rubyMethod)
    FWrapper->>FWrapper: Create RubyMethodWrapper
    Note over FWrapper: Wrapper stores RubyMethod
    Note over FWrapper: await=true for sync

    Client->>FWrapper: wrapper.apply(args)
    FWrapper->>FWrapper: inner_apply()
    FWrapper->>RubyRT: Bind thread
    FWrapper->>RubyRT: Call RubyMethod with Java args
    RubyRT->>RubyRT: Convert IRubyObject return
    RubyRT-->>FWrapper: Return Java Object
    FWrapper->>RubyRT: Unbind thread
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.70% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Add Ruby scripting extension' is concise, clear, and directly describes the main change: introducing a new JRuby language extension.
Description check ✅ Passed The description is comprehensive and directly related to the changeset. It provides context on the port, lists specific fixes, explains script bindings, documents the build matrix, and outlines testing performed.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified code

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (2)
extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/client/JRubyExtension.java (1)

26-26: Static singleton state ties languageDefinition to classloader lifetime.

Using a static field means the JRubyLanguageDefinition (and its cached runner) survives across Core reloads while the extension classloader is alive. If Core is ever recreated (e.g. config reload), subsequent calls to getLanguage(runner) return the definition bound to the old runner. Consider making it an instance field on JRubyExtension.

This mirrors how other extensions may already do it, so it may be an intentional existing pattern — flagging for awareness rather than blocking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/client/JRubyExtension.java`
at line 26, The static field languageDefinition in JRubyExtension holds
JRubyLanguageDefinition (and its cached runner) for the classloader lifetime and
can return a stale definition after Core reloads; change languageDefinition from
a static to an instance field on JRubyExtension, update any references (e.g.,
getLanguage(runner) and any constructors or factory methods that access
JRubyExtension.languageDefinition) to use the instance field, and ensure
JRubyLanguageDefinition is created/initialized per JRubyExtension instance so
the cached runner is bound to the current Core instance rather than the
classloader.
extension/ruby/build.gradle.kts (1)

22-23: Consider failing fast when stonecutter.active is empty/absent.

rootProject.file("stonecutter.active").readText().trim() will yield an empty string (or throw FNF) and then project(":common:") will resolve to an invalid path producing a confusing error. The root stonecutter.gradle.kts (Line 173-179) already does throw GradleException("stonecutter.active is empty; set an active version first") — worth adopting the same guard here (and in the graalpy script too).

This is the same pattern used by extension/graal/python/build.gradle.kts, so any fix should be applied consistently.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@extension/ruby/build.gradle.kts` around lines 22 - 23, The assignment that
computes val minecraftVersion via
rootProject.file("stonecutter.active").readText().trim() should fail fast when
the file is missing or empty: replace the direct read with a guard that checks
existence and non-empty content and throws a GradleException with the same
message used in stonecutter.gradle.kts ("stonecutter.active is empty; set an
active version first") if the file is absent or yields an empty string; apply
the same pattern you used in extension/graal/python/build.gradle.kts and the
graalpy script so all places that compute minecraftVersion (the symbol
minecraftVersion and the rootProject.file("stonecutter.active") read) behave
consistently.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/client/JRubyExtension.java`:
- Around line 98-108: The traceStack method can NPE on
current.getFileName()==null and risks unbounded recursion; update traceStack to
guard the file name before constructing a File (use a null-check and only create
BaseWrappedException.GuestLocation with a File when current.getFileName() !=
null, otherwise fall back to HostLocation or a GuestLocation variant that omits
the File), and replace the recursive walker with an iterative loop that builds
the BaseWrappedException chain tail-first (or enforce a max depth) to avoid
StackOverflowError; locate and change the traceStack method and its creation of
BaseWrappedException.GuestLocation/HostLocation to implement these fixes.
- Around line 67-75: The lazy initialization of languageDefinition in
getLanguage is not thread-safe; change getLanguage to perform initialization
under a lock (simplest: make the method synchronized) so only one thread can
construct JRubyLanguageDefinition and perform the Thread context-classloader
swap; alternatively implement double-checked locking with languageDefinition
declared volatile and the JRubyLanguageDefinition construction inside the
synchronized block—ensure the context-classloader set/reset
(Thread.currentThread().setContextClassLoader(...)) remains inside the
synchronized region so initialization is atomic.

In
`@extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/language/impl/JRubyLanguageDefinition.java`:
- Around line 60-65: The call in JRubyLanguageDefinition that passes
ctx.getCtx().getFile().getParentFile().toPath() into the execution context can
NPE because getParentFile() may be null even when getFile() is non-null; change
the ternary to first obtain File f = ctx.getCtx().getFile(), then File parent =
f == null ? null : f.getParentFile(), and pass parent != null ? parent.toPath()
: null to the runner; update the same pattern in the file-based overload
mentioned (the earlier runScriptlet overload) to guard against a null parent
directory as well.
- Line 49: Replace the FileReader usage in JRubyLanguageDefinition where
instance.runScriptlet(new FileReader(ctx.getCtx().getFile()),
ctx.getCtx().getFile().getAbsolutePath()) is called: open the file with
Files.newBufferedReader(ctx.getCtx().getFile().toPath(), StandardCharsets.UTF_8)
and pass that Reader to instance.runScriptlet(Reader, String) inside a
try-with-resources (or explicitly close the Reader) so the reader is correctly
UTF-8 decoded and always closed to avoid file-handle leaks.

In
`@extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/library/impl/FWrapper.java`:
- Around line 134-136: The compare method in FWrapper currently casts the Object
result of inner_apply(o1, o2) directly to int causing ClassCastException when
JRuby returns a Long; change compare in class FWrapper to obtain the numeric
result as a Number and call intValue() (e.g., call inner_apply(...) into a
Number variable and return number.intValue()), ensuring you handle nulls or
non-number returns appropriately; this references the methods FWrapper.compare
and inner_apply (and the Ruby-to-Java conversion performed by callFn).
- Around line 48-52: callFn pushes a new JRuby scope via
ThreadContext.pushNewScope but never pops it, causing scope stack leaks when
invoked from apply/test/compare/accept; fix by wrapping the call in a
try/finally that always pops the scope (use ThreadContext.popScope() or the
matching pop method on ThreadContext) so the scope is removed even on
exceptions, i.e., in callFn obtain the ThreadContext, pushNewScope, then in
finally call the matching pop method before returning or rethrowing.

---

Nitpick comments:
In `@extension/ruby/build.gradle.kts`:
- Around line 22-23: The assignment that computes val minecraftVersion via
rootProject.file("stonecutter.active").readText().trim() should fail fast when
the file is missing or empty: replace the direct read with a guard that checks
existence and non-empty content and throws a GradleException with the same
message used in stonecutter.gradle.kts ("stonecutter.active is empty; set an
active version first") if the file is absent or yields an empty string; apply
the same pattern you used in extension/graal/python/build.gradle.kts and the
graalpy script so all places that compute minecraftVersion (the symbol
minecraftVersion and the rootProject.file("stonecutter.active") read) behave
consistently.

In
`@extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/client/JRubyExtension.java`:
- Line 26: The static field languageDefinition in JRubyExtension holds
JRubyLanguageDefinition (and its cached runner) for the classloader lifetime and
can return a stale definition after Core reloads; change languageDefinition from
a static to an instance field on JRubyExtension, update any references (e.g.,
getLanguage(runner) and any constructors or factory methods that access
JRubyExtension.languageDefinition) to use the instance field, and ensure
JRubyLanguageDefinition is created/initialized per JRubyExtension instance so
the cached runner is bound to the current Core instance rather than the
classloader.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 621f874c-a6d9-4769-907d-7ef831ca23f0

📥 Commits

Reviewing files that changed from the base of the PR and between f32f661 and d823ffa.

📒 Files selected for processing (9)
  • extension/ruby/build.gradle.kts
  • extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/client/JRubyExtension.java
  • extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/language/impl/JRubyLanguageDefinition.java
  • extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/language/impl/JRubyScriptContext.java
  • extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/library/impl/FWrapper.java
  • extension/ruby/src/main/resources/META-INF/services/com.jsmacrosce.jsmacros.core.extensions.Extension
  • extension/ruby/src/main/resources/jsmacrosce.ext.jruby.json
  • settings.gradle.kts
  • stonecutter.gradle.kts

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant