Open Source Adventures: Episode 62: Ruby2JS

taw

Tomasz Wegrzanowski

Posted on May 23, 2022

Open Source Adventures: Episode 62: Ruby2JS

There are three main ways to run some sort of Ruby in a browser, none of them terribly satisfying:

  • WebAssembly - Ruby has limited support for it - you'll get good Ruby compatibility, and reasonable performance, but very poor JavaScript interoperability
  • Opal Ruby - compiles Ruby to JavaScript, making some serious compromises in terms of Ruby compatibility and performance to achieve better JavaScript interoperability
  • Ruby2JS - basically Ruby-like syntax for JavaScript, and not in any meaningful sense "Ruby" - minimal Ruby compatibility, but potentially good performance, and good JavaScript interoperability

Over previous few episodes we've taken a look at how Opal Ruby does things. So new I'll run all these examples in Ruby2JS.

Hello, World!

By default Ruby2JS targets obsolete JavaScript, but we can tell it to target modern platforms with some switches.

--es2022 goes a bit too far for me, using nasty JavaScript "private instance variables", which is not a feature we want, so I passed --underscored_private to disable that.

We also need to specify -f functions. Ruby2JS has a bunch of configurable "filters" to tweak code generation.

$ ruby2js --es2022 --underscored_private -f functions hello.rb >hello.js
Enter fullscreen mode Exit fullscreen mode
puts "Hello, World!"
Enter fullscreen mode Exit fullscreen mode

With default settings, it becomes:

puts("Hello, World!")
Enter fullscreen mode Exit fullscreen mode

This is already highly problematic, as Ruby2JS by design doesn't have runtime, so there's no puts. So by default, its level of compatibility with Ruby is so low, even Hello World will instantly crash.

Fortunately -f functions rescues us here, generating the obvious code:

console.log("Hello, World!")
Enter fullscreen mode Exit fullscreen mode

So we can at least run Hello, World. This matters a few more times, in all examples below I'll be using -f functions.

Booleans and Nils

a = true
b = false
c = nil
Enter fullscreen mode Exit fullscreen mode

Becomes:

let a = true;
let b = false;
let c = null
Enter fullscreen mode Exit fullscreen mode

For true and false it's obvious. Translating nil into null changes semantics a lot, but that's the cost of JavaScript interoperability.

Numbers

a = -420
b = 6.9
c = a + b
d = 999_999_999_999_999_999
e = a.abs
Enter fullscreen mode Exit fullscreen mode

Becomes:

let a = -420;
let b = 6.9;
let c = a + b;
let d = 999_999_999_999_999_999;
let e = Math.abs(a)
Enter fullscreen mode Exit fullscreen mode

Just like Opal, Ruby Integer and Float both become JavaScript number.

Ruby + is translated into a JavaScript +, not any kind of rb_plus. That's a performance win of course, but that means you cannot + arrays and such.

-f functions again saves us, without it .abs call is translated into nonsense.

Strings

a = "world"
b = :foo
c = "Hello, #{a}!"
Enter fullscreen mode Exit fullscreen mode

Becomes:

let a = "world";
let b = "foo";
let c = `Hello, ${a}!`
Enter fullscreen mode Exit fullscreen mode

So just like Opal Ruby, String and Symbol both become JavaScript string.

RubyJS will use string interpolation if we choose appropriate target. This makes no difference semantically, but it results in more readable code. Then again, Opal really doesn't care about readability of code it generates.

Arrays

a = []
b = [10, 20, 30]
b[2] = 40
b[-1] = b[-1] + 5
c = b[0]
d = b[-1]
Enter fullscreen mode Exit fullscreen mode

Becomes:

let a = [];
let b = [10, 20, 30];
b[2] = 40;
b[-1] = b.at(-1) + 5;
let c = b[0];
let d = b.at(-1)
Enter fullscreen mode Exit fullscreen mode

Which is a terrible translation, as negative indexes are not supported in JavaScript, and they're used in Ruby all the time.

Given new ES target, -f functions translates negative getters to .at, but not negative setters, so we get something crazy inconsistent here. The b[-1] = b.at(-1) + 5; line is just total nonsense, it's likely even worse than not supporting negative indexes at all.

Hashes

a = {}
b = { 10 => 20, 30 => 40 }
c = { hello: "world" }
Enter fullscreen mode Exit fullscreen mode

Becomes:

let a = {};
let b = {[10]: 20, [30]: 40};
let c = {hello: "world"}
Enter fullscreen mode Exit fullscreen mode

Translating Ruby Hashes into JavaScript objects destroys most of their functionality, but it's more interoperable, and can be good enough for some very simple code.

Arguably ES6+ Map would fit Ruby semantics better, and it's part of the platform, but ES6 Maps have horrendously poor interoperability with any existing JavaScript code. For example JSON.stringify(new Map([["hello", "world"]])) returns '{}', which is insane.

Simple Person class

class Person
  def initialize(first_name, last_name)
    @first_name = first_name
    @last_name = last_name
  end

  def to_s
    "#{@first_name} #{@last_name}"
  end
end

person = Person.new("Alice", "Ruby")
puts "Hello, #{person}!"
Enter fullscreen mode Exit fullscreen mode

Becomes:

class Person {
  constructor(first_name, last_name) {
    this._first_name = first_name;
    this._last_name = last_name
  };

  get to_s() {
    return `${this._first_name} ${this._last_name}`
  }
};

let person = new Person("Alice", "Ruby");
console.log(`Hello, ${person}!`)
Enter fullscreen mode Exit fullscreen mode

Which looks very nice, but of course it doesn't work, as to_s means nothing in JavaScript, so it prints Hello, [object Object]!.

To get it to actually work, we need to twist it into something like:

class Person
  def initialize(first_name, last_name)
    @first_name = first_name
    @last_name = last_name
  end

  def toString()
    return "#{@first_name} #{@last_name}"
  end
end

person = Person.new("Alice", "Ruby")
puts "Hello, #{person}!"
Enter fullscreen mode Exit fullscreen mode

Notice three changes:

  • to_s becomes toString
  • mandatory () after toString - otherwise it's a getter not function, and that won't work
  • mandatory return (there's a filter for that, but I didn't check if it breaks anything else)

If you had any hopes that any nontrivial Ruby code will run in Ruby2JS, you should see by now that it's hopeless.

Inheritance

class Person
  def initialize(first_name, last_name)
    @first_name = first_name
    @last_name = last_name
  end

  def toString()
    return "#{@first_name} #{@last_name}"
  end
end

class Cat < Person
  def toString()
    return "Your Majesty, Princess #{super}"
  end
end

cat = Cat.new("Catherine", "Whiskers")
puts "Hello, #{cat}!"
Enter fullscreen mode Exit fullscreen mode

Becomes:

class Person {
  constructor(first_name, last_name) {
    this._first_name = first_name;
    this._last_name = last_name
  };

  toString() {
    return `${this._first_name} ${this._last_name}`
  }
};

class Cat extends Person {
  toString() {
    return `Your Majesty, Princess ${super.toString()}`
  }
};

let cat = new Cat("Catherine", "Whiskers");
console.log(`Hello, ${cat}!`)
Enter fullscreen mode Exit fullscreen mode

Story so far

Overall it's really unclear to me what are legitimate use cases for Ruby2JS. Its compatibility with Ruby is nearly nonexistent, you're about as likely to be able to run your Ruby code in Crystal or Elixir as in Ruby2JS. So at this point, why not just create a full Ruby-inspired programming language that compiles to JavaScript?

If all you want is better syntax, CoffeeScript 2 is one such attempt (which is unfortunately not Svelte-compatible, if it was, I'd consider it), and it's not hard to create another.

And it's not even possible to create any reusable Ruby2JS code, as different combinations of filters and target will completely change meaning of the code.

All the code is on GitHub.

Coming next

In the next episode we'll go back to Opal Ruby.

đź’– đź’Ş đź™… đźš©
taw
Tomasz Wegrzanowski

Posted on May 23, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related