Open Source Adventures: Episode 62: Ruby2JS
Tags: javascript, ruby
Published: 2022-05-23 23:56:44

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
```

```ruby
puts "Hello, World!"
```

With default settings, it becomes:

```javascript
puts("Hello, World!")
```

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:

```javascript
console.log("Hello, World!")
```

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

```ruby
a = true
b = false
c = nil
```

Becomes:

```javascript
let a = true;
let b = false;
let c = null
```

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

### Numbers

```ruby
a = -420
b = 6.9
c = a + b
d = 999_999_999_999_999_999
e = a.abs
```

Becomes:

```javascript
let a = -420;
let b = 6.9;
let c = a + b;
let d = 999_999_999_999_999_999;
let e = Math.abs(a)
```

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

```ruby
a = "world"
b = :foo
c = "Hello, #{a}!"
```

Becomes:

```javascript
let a = "world";
let b = "foo";
let c = `Hello, ${a}!`
```

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

```ruby
a = []
b = [10, 20, 30]
b[2] = 40
b[-1] = b[-1] + 5
c = b[0]
d = b[-1]
```

Becomes:

```javascript
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)
```

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

```ruby
a = {}
b = { 10 => 20, 30 => 40 }
c = { hello: "world" }
```

Becomes:

```javascript
let a = {};
let b = {[10]: 20, [30]: 40};
let c = {hello: "world"}
```

Translating Ruby `Hash`es 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 `Map`s have horrendously poor interoperability with any existing JavaScript code. For example `JSON.stringify(new Map([["hello", "world"]]))` returns `'{}'`, which is insane.

### Simple Person class

```ruby
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}!"
```

Becomes:

```javascript
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}!`)
```

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:

```ruby
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}!"
```

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

```ruby
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}!"
```

Becomes:

```javascript
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}!`)
```

### 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](https://github.com/taw/open-source-adventures).

### Coming next

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