Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/workflows/wasm.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: WebAssembly

on:
push:
branches:
- master
pull_request:
paths:
- ".github/workflows/wasm.yml"
- "Rakefile"
- "include/**"
- "src/**"
- "wasm/**"

permissions:
contents: read

env:
# Pinned so the smoke test is reproducible. Bump together when upgrading.
WASI_SDK_VERSION: "33"
WASI_SDK_RELEASE: "33.0"
WASMTIME_VERSION: "v45.0.2"

jobs:
build:
name: wasm:check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- run: git fetch --depth=1 origin +refs/tags/*:refs/tags/*
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: ruby
bundler: none
- name: Update rubygems & bundler
run: gem update --system
- name: Install gems
run: |
bundle config set --local without libs:profilers
bundle install --jobs 4 --retry 3
- name: Install the WASI SDK
run: |
url="https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${WASI_SDK_VERSION}/wasi-sdk-${WASI_SDK_RELEASE}-x86_64-linux.tar.gz"
mkdir -p "$HOME/wasi-sdk"
curl -sSL "$url" | tar xz --strip-components=1 -C "$HOME/wasi-sdk"
echo "WASI_SDK_PATH=$HOME/wasi-sdk" >> "$GITHUB_ENV"
- name: Install wasmtime
uses: bytecodealliance/actions/wasmtime/setup@v1
with:
version: ${{ env.WASMTIME_VERSION }}
- name: Build and smoke-test the WebAssembly module
run: bundle exec rake wasm:check
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,6 @@ ext/rbs_extension/.cache
# Rust crate vendored RBS source (managed by rake rust:rbs:sync or rust:rbs:symlink)
rust/ruby-rbs-sys/vendor/rbs/
rust/ruby-rbs/vendor/rbs/

# Compiled WebAssembly module (built by rake wasm:build)
wasm/*.wasm
68 changes: 68 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,74 @@ task :prepare_profiling do
Rake::Task[:"compile"].invoke
end

namespace :wasm do
WASM_DIR = File.expand_path("wasm", __dir__)
WASM_OUTPUT = File.join(WASM_DIR, "rbs_parser.wasm")

# The parser under src/ is plain, self-contained C with no dependency on the
# Ruby C API, so it can be compiled to WebAssembly as-is. The only extra
# translation unit is the entry-point shim under wasm/.
def wasm_source_files
Dir.glob(File.join(__dir__, "src/**/*.c")).sort + [File.join(WASM_DIR, "rbs_wasm.c")]
end

# Locate the clang shipped with the WASI SDK.
#
# The system clang can target wasm32, but the WASI SDK additionally provides
# the wasi-libc sysroot and the wasm32 compiler-rt builtins that the link
# step needs, so we require it explicitly.
def wasi_clang
sdk = ENV["WASI_SDK_PATH"]
if sdk.nil? || sdk.empty?
raise <<~MSG
WASI_SDK_PATH is not set.

Install the WASI SDK from https://github.com/WebAssembly/wasi-sdk/releases
and point WASI_SDK_PATH at the extracted directory, for example:

export WASI_SDK_PATH=/opt/wasi-sdk
rake wasm:build
MSG
end

clang = File.join(sdk, "bin", "clang")
raise "clang not found at #{clang} (is WASI_SDK_PATH correct?)" unless File.executable?(clang)

clang
end

desc "Build the RBS parser as a WebAssembly module (requires WASI_SDK_PATH)"
task :build do
mkdir_p WASM_DIR
sh wasi_clang,
"--target=wasm32-wasip1",
# No `main`; the host calls `_initialize` and then the exported functions.
"-mexec-model=reactor",
"-std=gnu11",
"-O2",
"-Wno-unused-parameter",
"-I#{File.join(__dir__, "include")}",
"-o", WASM_OUTPUT,
*wasm_source_files
puts "Built #{WASM_OUTPUT}"
end

desc "Build and smoke-test the WebAssembly module (requires wasmtime)"
task :check => :build do
wasmtime = ENV["WASMTIME"] || "wasmtime"

# `rbs_wasm_selftest` parses a small fixed signature and returns 0 on
# success. `--invoke` prints the return value to stdout.
output = IO.popen([wasmtime, "run", "--invoke", "rbs_wasm_selftest", WASM_OUTPUT], err: File::NULL, &:read).to_s.strip

if output == "0"
puts "WebAssembly selftest passed."
else
raise "WebAssembly selftest failed: rbs_wasm_selftest returned #{output.inspect} (expected \"0\")"
end
end
end

namespace :rust do
namespace :rbs do
RUST_DIR = File.expand_path("rust", __dir__)
Expand Down
5 changes: 1 addition & 4 deletions src/util/rbs_allocator.c
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,7 @@
#ifdef _WIN32
#include <windows.h>
#else
#include <unistd.h>
#include <sys/types.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h> // for sysconf()
#endif

typedef struct rbs_allocator_page {
Expand Down
49 changes: 49 additions & 0 deletions wasm/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# RBS parser as WebAssembly

The parser under [`src/`](../src) is plain, self-contained C with no dependency
on the Ruby C API, so it can be compiled to WebAssembly as-is. This directory
holds the small entry-point shim ([`rbs_wasm.c`](rbs_wasm.c)) that exposes a
stable ABI to a WebAssembly host.

The motivating use case is running RBS on Ruby implementations that cannot load
the MRI C extension (notably JRuby): the host loads `rbs_parser.wasm`, runs the
parser over a source buffer, and reads the result back out — no native build per
platform required.

## Building

The build needs the [WASI SDK](https://github.com/WebAssembly/wasi-sdk/releases)
(for `clang`, the wasi-libc sysroot, and the wasm32 compiler-rt builtins):

```console
$ export WASI_SDK_PATH=/path/to/wasi-sdk
$ rake wasm:build
Built .../wasm/rbs_parser.wasm
```

To also run the smoke test you need [wasmtime](https://wasmtime.dev/) (or another
WASI runtime, via the `WASMTIME` environment variable):

```console
$ rake wasm:check
WebAssembly selftest passed.
```

The compiled `rbs_parser.wasm` is a build artifact and is not checked in.

## Exported functions

The module is built as a "reactor": it has no `main`, and the host calls
`_initialize` once before invoking any export.

| Export | Signature | Description |
| -------------------------- | --------------------- | ------------------------------------------------------------------------ |
| `rbs_wasm_alloc` | `(i32) -> i32` | Allocate N bytes in linear memory and return the offset. |
| `rbs_wasm_free` | `(i32) -> ()` | Free a region returned by `rbs_wasm_alloc`. |
| `rbs_wasm_parse_signature` | `(i32 ptr, i32 len) -> i32` | Parse the UTF-8 source at `ptr`/`len`. Returns 0 on success, 1 on error. |
| `rbs_wasm_selftest` | `() -> i32` | Parse a small fixed signature. Returns 0 on success, 1 otherwise. |

This is the foundation step: it proves the parser builds and runs under
WebAssembly. Subsequent steps add a compact serialization of the parsed AST so
the host can reconstruct `RBS::AST` objects, and wire the module into RBS on
JRuby through a JVM WebAssembly runtime.
89 changes: 89 additions & 0 deletions wasm/rbs_wasm.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* @file rbs_wasm.c
*
* WebAssembly entry points for the RBS parser.
*
* The RBS parser in `src/` is plain, self-contained C with no dependency on
* the Ruby C API. This file exposes a small, stable ABI so that the parser can
* be driven from a WebAssembly host (for example, a JVM-based runtime running
* under JRuby).
*
* This module is built as a "reactor" (`-mexec-model=reactor`): it has no
* `main`, and the host is expected to call `_initialize` once before invoking
* any of the exported functions below.
*
* For now this only proves the toolchain end to end: it can allocate memory in
* the linear address space, run the parser over a source buffer, and report
* whether parsing succeeded. Serializing the resulting AST back to the host is
* handled in a later step.
*/

#include <stdlib.h>
#include <string.h>
#include <stdint.h>

#include "rbs/parser.h"
#include "rbs/string.h"
#include "rbs/util/rbs_encoding.h"

/**
* Allocate `size` bytes in the module's linear memory and return the offset.
*
* The host uses this to reserve a region it can write an input string into
* before calling one of the parse entry points.
*/
__attribute__((export_name("rbs_wasm_alloc"))) void *rbs_wasm_alloc(size_t size) {
return malloc(size);
}

/**
* Free a region previously returned by `rbs_wasm_alloc`.
*/
__attribute__((export_name("rbs_wasm_free"))) void rbs_wasm_free(void *ptr) {
free(ptr);
}

/**
* Parse an RBS signature from a UTF-8 source buffer.
*
* @param source Offset of the source buffer in linear memory.
* @param length Length of the source buffer, in bytes.
* @return 0 if parsing succeeded, 1 if a parse error occurred.
*/
__attribute__((export_name("rbs_wasm_parse_signature"))) int rbs_wasm_parse_signature(const char *source, int length) {
rbs_string_t string = rbs_string_new(source, source + length);
const rbs_encoding_t *encoding = RBS_ENCODING_UTF_8_ENTRY;
rbs_parser_t *parser = rbs_parser_new(string, encoding, 0, length);

rbs_signature_t *signature = NULL;
bool ok = rbs_parse_signature(parser, &signature);

int result = (ok && parser->error == NULL) ? 0 : 1;

rbs_parser_free(parser);

return result;
}

/**
* Parse a small, fixed RBS document.
*
* This exercises the whole parser path inside WebAssembly without the host
* having to write anything into linear memory, which makes it convenient as a
* build smoke test (`wasmtime run --invoke rbs_wasm_selftest rbs_parser.wasm`).
*
* @return 0 if the sample parsed successfully, 1 otherwise.
*/
__attribute__((export_name("rbs_wasm_selftest"))) int rbs_wasm_selftest(void) {
static const char source[] =
"class User\n"
" attr_reader name: String\n"
" def initialize: (String name) -> void\n"
"end\n"
"\n"
"module Authentication\n"
" def authenticate: (String, String) -> bool\n"
"end\n";

return rbs_wasm_parse_signature(source, (int) (sizeof(source) - 1));
}
Loading