DEV Community

Tomasz Wegrzanowski
Tomasz Wegrzanowski

Posted on

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.

Top comments (4)

Collapse
 
rubydesign profile image
Torsten Rüger

Ok, that does come across a little negative, but understandable if your don't "get" it.

The point is not to write javascript

Not to have the dissonance in your head, when you are on one page, writing two programming languages.
Also this (like opal) started long before JS got tamed.

Off course one needs to know what one is doing to a degree. But i used it in many web projects (usually vue) and had code reductions of almost half, and the sense of beauty that comes with ruby.

Maybe those cli examples were not a good idea (made you miss the point, the joy of it), i don't think anyone uses it outside the web

Collapse
 
taw profile image
Tomasz Wegrzanowski

The point is not to write javascript

writing two programming languages

The problem with ruby2js is that you're still writing 2 languages.

Opal Ruby are close enough to Ruby that you can maybe see it as the same language. Webasm Ruby promises to do even better. They only run into trouble when you need to interact with JS libraries like React, or need to debug things etc.

Ruby2js is JS in every way except for very thin layer of syntax. You are still writing JavaScript, it just looks a bit nicer.

I can definitely see code size reduction relative to old style JS, but I'm not sure it would be that big compared to modern JS. If anyone has good examples, I'd love to see them.

Collapse
 
rubydesign profile image
Torsten Rüger

Sure, examples.
So here is an app that sorts and filters images in 23 lines of ruby2js: github.com/HubFeenixMakers/merged/...
An app that makes complex purchase orders possible, in 3 interconnected tabs: github.com/rubydesign/rubydesign.f...
And the only one you can see working (ie is on the public internet) is a 3d application to design lamp shades using vue and threejs. In less than 60 lines of code.github.com/rubydesign/rubydesign.f...
And online here rubydesign.fi/3d/bell_shade

I don't really think making statements like this "the problem with ruby2js" is quite appropriate ifif you have not made the effort to see it's benefit. There must be good there that the authors and users (like me) see, that you in your own words don't. Comparing it with coffee really showed you don't understand what the benefit is.

Comparing it with opal also shows some misunderstanding and lack of experience. Opal redirects every call and as such makes it quite difficult to interact with such sophisticated machinery as vue, dare i say impossible. I managed to get a 5000 lines pure ruby project into the browser with opal, which is amazing, but interoperability with js frameworks, i don't know.

And the main thing is that one is not writing JS that looks nicer. One writes ruby, that has a few restrictions. But if you put the functions filter on (as everone does), a good es level and some advanced filter like vue, you get soo much translation. Ie ruby hashes work, ruby methods work, method calls work, instance variable work, no brackets work, classes work, derivation works . . . (that is hardly "tthin") Sure, you have to know about the translation, but it's like working with eg haml (being translated into html), one just forgets, it becomes automatic.

So have a look, and maybe try out a bit more than in that post. The conclusion of that was to tell people that you don't see the use case. As a writer one would think that that is the point to ask users, maybe even authors. Because surely there must be one, why else would people spend their weekends on it. The conclusion you draw seems to say that the authors have a made a bit of a mistake, whereas off course it really just reveals that you did not bother to find out what those cases are. I hope the examples above help in seeing.

Thread Thread
 
taw profile image
Tomasz Wegrzanowski

So out of curiosity, I pasted your examples to ruby2js's online compiler with ES2022 target (all parts in :ruby2js blocks) and code expansion is minimal - mostly just extra whitespace in output like between defs and in long defines.

If I set target to pre-ES6 then yes, you get significant code savings.

For example purchase-app has 2327 nonspace characters in Ruby version, 2314 in ES2022 version (<1% difference), and 2571 in old style JS version (+10%). I'm checking nonspace to avoid indentation differences, as that Ruby code is very aggressively underspaced relative to usual conventions.

ThreeJS example - Ruby 1504, old JS 1687 (+12%), new JS 1580 (+5%)

Image sorting app - Ruby 447, old JS 683 (+52%), new JS 479 (+7%).

This is of course unfair to JS, as generated code is not necessarily as clean as handwritten one would be.

Anyway, I'm not seeing "code reductions of almost half" here. More like "code reduction of about 5%".

And to show why coffeescript is relevant. There is no js2coffee for modern JS, but I pasted ruby2js's output (pre-es mode) into js2coffee, and it got 2185 (-5%) on purchase app and 555 on image sorting (+24%) (it didn't like ThreeJS generated code). This is only tangential as js2coffee comes from pre-ES6 days, and was never updated to CoffeeScript 2 or for modern JS, but you get fairly comparable savings.