diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml new file mode 100644 index 000000000..6bdcf8adf --- /dev/null +++ b/.github/workflows/wasm.yml @@ -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 diff --git a/.gitignore b/.gitignore index 3d723e6d4..e7e523cd4 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Rakefile b/Rakefile index 5a2e13af7..8fe05a9d7 100644 --- a/Rakefile +++ b/Rakefile @@ -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__) diff --git a/src/util/rbs_allocator.c b/src/util/rbs_allocator.c index f088dea37..2a3f17076 100644 --- a/src/util/rbs_allocator.c +++ b/src/util/rbs_allocator.c @@ -25,10 +25,7 @@ #ifdef _WIN32 #include #else -#include -#include -#include -#include +#include // for sysconf() #endif typedef struct rbs_allocator_page { diff --git a/wasm/README.md b/wasm/README.md new file mode 100644 index 000000000..1fd81d452 --- /dev/null +++ b/wasm/README.md @@ -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. diff --git a/wasm/rbs_wasm.c b/wasm/rbs_wasm.c new file mode 100644 index 000000000..41a35051f --- /dev/null +++ b/wasm/rbs_wasm.c @@ -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 +#include +#include + +#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)); +}