|
| 1 | +# Running PHPantom in the browser (WebAssembly) |
| 2 | + |
| 3 | +PHPantom compiles to WebAssembly, so the same type engine that powers the |
| 4 | +native language server can run inside a web editor with no server round-trip. |
| 5 | +This is how the [PHPStan playground](https://phpstan.org/try) gets completion, |
| 6 | +hover, go-to-definition, symbol highlighting and rename. |
| 7 | + |
| 8 | +## Prebuilt module |
| 9 | + |
| 10 | +Every release has a `phpantom_lsp-wasm32-wasip1.tar.gz` asset containing the |
| 11 | +`phpantom_lsp.wasm` module, built and smoke-tested by CI, so a host can pin a |
| 12 | +released version instead of building its own. Build it yourself if you want to |
| 13 | +run against unreleased changes. |
| 14 | + |
| 15 | +## Build |
| 16 | + |
| 17 | +```bash |
| 18 | +rustup target add wasm32-wasip1 |
| 19 | + |
| 20 | +cargo rustc --lib --crate-type cdylib \ |
| 21 | + --profile wasm-release --target wasm32-wasip1 |
| 22 | +``` |
| 23 | + |
| 24 | +The module lands in `target/wasm32-wasip1/wasm-release/phpantom_lsp.wasm`. It is |
| 25 | +around 15 MiB raw and 3 MiB gzipped, most of which is the embedded |
| 26 | +phpstorm-stubs; compressing with Brotli instead gets it appreciably smaller |
| 27 | +still. |
| 28 | + |
| 29 | +Two things about that command are deliberate: |
| 30 | + |
| 31 | +- **`--crate-type cdylib` on the command line** rather than a `crate-type` |
| 32 | + entry in `Cargo.toml`. Declaring `cdylib` in the manifest would make every |
| 33 | + native `cargo build` and `cargo test` link an extra shared library that |
| 34 | + nothing uses. |
| 35 | +- **`--profile wasm-release`** rather than `--profile release`. It inherits |
| 36 | + from `release` but optimizes for size (`opt-level = "s"`), which is the right |
| 37 | + trade in a browser. |
| 38 | + |
| 39 | +## Target choice: WASI, not `wasm32-unknown-unknown` |
| 40 | + |
| 41 | +The build targets `wasm32-wasip1`, so the standard library's filesystem, clock |
| 42 | +and path APIs are all present, since `file://` URIs and paths are used in dozens of places, also WASI |
| 43 | +`Url::to_file_path` and friends work unchanged. On `wasm32-unknown-unknown` the |
| 44 | +`url` crate omits those methods entirely, and every call site needs shimming. |
| 45 | + |
| 46 | +## Host interface |
| 47 | + |
| 48 | +The module is a WASI **reactor**: instantiate it once, call `_initialize`, then |
| 49 | +push LSP messages through it for the lifetime of the editor session. It exports |
| 50 | +four functions: |
| 51 | + |
| 52 | +| Export | Purpose | |
| 53 | +| --- | --- | |
| 54 | +| `lsp_alloc(len) -> ptr` | Allocate `len` bytes of linear memory for the host to write a request into. | |
| 55 | +| `lsp_handle(ptr, len) -> ptr` | Handle one LSP JSON-RPC message. Returns the response buffer, or null for a notification. | |
| 56 | +| `lsp_response_len() -> len` | Length of the buffer the last `lsp_handle` returned. | |
| 57 | +| `lsp_dealloc(ptr, len)` | Free a buffer obtained from `lsp_alloc` or `lsp_handle`. | |
| 58 | + |
| 59 | +The response length is a separate call so `lsp_handle` can return a plain `u32` |
| 60 | +pointer; packing pointer and length into a `u64` would surface as a `BigInt` in |
| 61 | +JavaScript. |
| 62 | + |
| 63 | +Messages are ordinary LSP JSON-RPC, which means a browser LSP client such as |
| 64 | +[`@codemirror/lsp-client`](https://www.npmjs.com/package/@codemirror/lsp-client) |
| 65 | +can be pointed at it through a thin transport. `initialize` advertises what the |
| 66 | +dispatcher in `src/lsp_dispatch.rs` actually routes: completion (with |
| 67 | +`completionItem/resolve`), hover, definition, document highlight, rename and |
| 68 | +signature help, with full-text document sync. |
| 69 | + |
| 70 | +In the browser, supply the WASI imports with a shim such as |
| 71 | +[`@bjorn3/browser_wasi_shim`](https://www.npmjs.com/package/@bjorn3/browser_wasi_shim). |
| 72 | +Nothing in the LSP path needs a real filesystem: the stubs are embedded in the |
| 73 | +module and open documents are held in memory. |
| 74 | + |
| 75 | +A host loop looks like this: |
| 76 | + |
| 77 | +```js |
| 78 | +const payload = new TextEncoder().encode(JSON.stringify(message)); |
| 79 | +const inPtr = exports.lsp_alloc(payload.length); |
| 80 | +new Uint8Array(exports.memory.buffer, inPtr, payload.length).set(payload); |
| 81 | + |
| 82 | +const outPtr = exports.lsp_handle(inPtr, payload.length); |
| 83 | +exports.lsp_dealloc(inPtr, payload.length); |
| 84 | + |
| 85 | +if (outPtr !== 0) { |
| 86 | + const len = exports.lsp_response_len(); |
| 87 | + // Copy before the next wasm call: growing linear memory detaches this view. |
| 88 | + const bytes = new Uint8Array(exports.memory.buffer, outPtr, len).slice(); |
| 89 | + exports.lsp_dealloc(outPtr, len); |
| 90 | + response = JSON.parse(new TextDecoder().decode(bytes)); |
| 91 | +} |
| 92 | +``` |
| 93 | + |
| 94 | +## Verifying a build |
| 95 | + |
| 96 | +`scripts/wasm-smoke-test.mjs` drives the module under Node's built-in WASI the |
| 97 | +same way a browser host would, and asserts that completion, hover, definition, |
| 98 | +highlight and signature help all return real results: |
| 99 | + |
| 100 | +```bash |
| 101 | +node scripts/wasm-smoke-test.mjs |
| 102 | +``` |
| 103 | + |
| 104 | +It takes the path to the `.wasm` as an optional argument and defaults to the |
| 105 | +`wasm-release` build above. |
| 106 | + |
| 107 | +## What the wasm build leaves out |
| 108 | + |
| 109 | +The wasm module is the *per-file* language-server path: parse a buffer, index |
| 110 | +it, answer requests about it. Whole-project features are compiled out or simply |
| 111 | +absent, because they depend on things wasm does not have: |
| 112 | + |
| 113 | +- **The stdio and TCP transports.** The host calls `lsp_handle` directly, so |
| 114 | + `tower-lsp`'s transport (and the `tokio` `net` feature, which pulls in `mio`) |
| 115 | + is not built. `tokio` is declared per-target in `Cargo.toml` for this reason. |
| 116 | +- **Parallel project indexing.** The `analyze` and `fix` batch paths spawn OS |
| 117 | + worker threads. That is project indexing, not what an editor needs per |
| 118 | + keystroke. |
| 119 | + |
| 120 | +Diagnostics are not routed by the dispatcher either. `Backend::collect_slow_diagnostics` is synchronous and does work |
| 121 | +in wasm, so wiring `textDocument/publishDiagnostics` is a matter of adding a |
| 122 | +route in `src/lsp_dispatch.rs` if a host wants it. |
0 commit comments