Skip to content

Commit 931df7c

Browse files
committed
Add WASM docs and CI
1 parent c83ba88 commit 931df7c

5 files changed

Lines changed: 200 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,31 @@ jobs:
6767
env:
6868
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
6969

70+
wasm:
71+
name: WebAssembly
72+
runs-on: ubuntu-latest
73+
steps:
74+
- uses: actions/checkout@v4
75+
- uses: dtolnay/rust-toolchain@stable
76+
with:
77+
components: clippy
78+
targets: wasm32-wasip1
79+
- uses: Swatinem/rust-cache@v2
80+
with:
81+
shared-key: wasm
82+
# `--target` on the command line overrides the musl CARGO_BUILD_TARGET
83+
# this workflow sets for every other job.
84+
- run: cargo clippy --lib --target wasm32-wasip1 -- -D warnings
85+
- name: Build the wasm reactor
86+
run: |
87+
cargo rustc --lib --crate-type cdylib \
88+
--profile wasm-release --target wasm32-wasip1
89+
- uses: actions/setup-node@v4
90+
with:
91+
node-version: "22"
92+
- name: Smoke-test the wasm reactor
93+
run: node scripts/wasm-smoke-test.mjs
94+
7095
php-lint:
7196
name: PHP Lint
7297
runs-on: ubuntu-latest

.github/workflows/release.yml

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,9 +203,54 @@ jobs:
203203
name: phpantom_lsp-${{ matrix.asset_target }}
204204
path: phpantom_lsp-${{ matrix.asset_target }}.${{ matrix.archive }}
205205

206+
# The WebAssembly module for in-browser hosts (see docs/wasm.md). Kept out
207+
# of the matrix above: it needs none of the signing, cross-compilation, or
208+
# musl setup those targets carry, and it ships a library rather than an
209+
# executable.
210+
wasm:
211+
name: Build wasm32-wasip1
212+
runs-on: ubuntu-latest
213+
steps:
214+
- uses: actions/checkout@v4
215+
with:
216+
ref: ${{ env.RELEASE_TAG }}
217+
218+
- uses: dtolnay/rust-toolchain@stable
219+
with:
220+
targets: wasm32-wasip1
221+
222+
- uses: Swatinem/rust-cache@v2
223+
with:
224+
key: wasm32-wasip1
225+
226+
- name: Build
227+
env:
228+
PHPANTOM_GIT_VERSION: ${{ env.RELEASE_TAG }}
229+
run: |
230+
cargo rustc --lib --crate-type cdylib \
231+
--profile wasm-release --target wasm32-wasip1
232+
233+
- uses: actions/setup-node@v4
234+
with:
235+
node-version: "22"
236+
237+
- name: Smoke-test the wasm reactor
238+
run: node scripts/wasm-smoke-test.mjs
239+
240+
- name: Package
241+
run: |
242+
tar czf phpantom_lsp-wasm32-wasip1.tar.gz \
243+
-C target/wasm32-wasip1/wasm-release phpantom_lsp.wasm
244+
245+
- name: Upload artifact
246+
uses: actions/upload-artifact@v4
247+
with:
248+
name: phpantom_lsp-wasm32-wasip1
249+
path: phpantom_lsp-wasm32-wasip1.tar.gz
250+
206251
release:
207252
name: Attach assets to draft
208-
needs: build
253+
needs: [build, wasm]
209254
runs-on: ubuntu-latest
210255
steps:
211256
- name: Download all artifacts

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ You may want to jump to:
3232
- [Editor Setup](editor-setup.md) and [Manual Installation](installation.md)
3333
- [Configuration Reference](configuration.md)
3434
- [CLI Reference](cli.md)
35+
- [Running in the browser (WebAssembly)](wasm.md)
3536
- [Development Roadmap](todo.md)
3637
- [Changelog](CHANGELOG.md)
3738
- [Benchmarks](benchmarks.md)

docs/todo.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,13 @@ unlikely to move the needle for most users.
7777
| T27 | [Per-expression type caching during forward walk](todo/type-inference.md#t27-per-expression-type-caching-during-forward-walk) | Medium-High | Medium |
7878
| T2 | [File system watching for vendor and project changes](todo/type-inference.md#t2-file-system-watching-for-vendor-and-project-changes) | Medium-High | Medium |
7979
| T28 | [Template inference depth priority (shallowest bound wins)](todo/type-inference.md#t28-template-inference-depth-priority-shallowest-bound-wins) | Medium | Low-Medium |
80+
| T35 | [Readonly property reassignment outside its declaring scope is never flagged](todo/type-inference.md#t35-readonly-property-reassignment-outside-its-declaring-scope-is-never-flagged) | Medium | Low-Medium |
8081
| T29 | [Definite vs possible variable existence tracking](todo/type-inference.md#t29-definite-vs-possible-variable-existence-tracking) | Medium | Medium |
8182
| T3 | [Property hooks (PHP 8.4)](todo/type-inference.md#t3-property-hooks-php-84) | Medium | Medium |
8283
| T25 | [Call-site template argument inference for callable parameters](todo/type-inference.md#t25-call-site-template-argument-inference-for-callable-parameters) (partially done) | Medium | Medium |
8384
| T32 | [Audit `is_type_compatible`'s MAYBE escape hatches for core-engine gaps](todo/type-inference.md#t32-audit-is_type_compatibles-maybe-escape-hatches-for-core-engine-gaps) | Medium | Medium |
8485
| T34 | [`static::CONST` over-narrows to the declaring class's value](todo/type-inference.md#t34-staticconst-over-narrows-to-the-declaring-classs-value) | Medium | Medium |
86+
| T36 | [Array-shape and generic enforcement gaps mir already covers](todo/type-inference.md#t36-array-shape-and-generic-enforcement-gaps-mir-already-covers) | Medium | Medium |
8587
| T30 | [Literal type collapse limit](todo/type-inference.md#t30-literal-type-collapse-limit) | Low-Medium | Low |
8688
| T24 | [`stdClass` dynamic property access](todo/type-inference.md#t24-stdclass-dynamic-property-access) | Low-Medium | Low |
8789
| T6 | `Closure::bind()` / `Closure::fromCallable()` return type preservation | Low-Medium | Low-Medium |
@@ -102,9 +104,13 @@ unlikely to move the needle for most users.
102104
| D5 | [External tool diagnostic suppression actions](todo/diagnostics.md#d5-external-tool-diagnostic-suppression-actions) | Low | Low |
103105
| D15 | [Unused parameter diagnostic](todo/diagnostics.md#d15-unused-parameter-diagnostic) | Low | Low |
104106
| | **[Bug Fixes](todo/bugs.md)** | | |
107+
| B77 | [An unrecognized PHPDoc pseudo-type spelling is treated as a class name and enforced literally](todo/bugs.md#b77-an-unrecognized-phpdoc-pseudo-type-spelling-is-treated-as-a-class-name-and-enforced-literally) | Medium-High | Medium |
105108
| B71 | [Completion in a Blade template edits the virtual PHP's coordinates](todo/bugs.md#b71-completion-in-a-blade-template-edits-the-virtual-phps-coordinates) | Medium | Low |
106109
| B72 | [A generic class named without its arguments returns its own template parameter](todo/bugs.md#b72-a-generic-class-named-without-its-arguments-returns-its-own-template-parameter) | Medium | Medium |
107110
| B73 | [Narrowing survives a reassignment of the subject's base variable](todo/bugs.md#b73-narrowing-survives-a-reassignment-of-the-subjects-base-variable) | Medium | Medium |
111+
| B74 | [`analyze --format json` prints a prose note ahead of the payload](todo/bugs.md#b74-analyze-format-json-prints-a-prose-note-ahead-of-the-payload) | Medium | Low |
112+
| B75 | [A path that does not exist is reported as "No PHP files found" with exit 0](todo/bugs.md#b75-a-path-that-does-not-exist-is-reported-as-no-php-files-found-with-exit-0) | Medium | Low |
113+
| B76 | [`{@see method()}` unqualified inside its own class resolves as a global function](todo/bugs.md#b76-see-method-unqualified-inside-its-own-class-resolves-as-a-global-function) | Medium | Medium |
108114
| | **[Code Actions](todo/actions.md)** | | |
109115
| A40 | [Generate method from call](todo/actions.md#a40-generate-method-from-call) | Medium-High | Medium |
110116
| A41 | [Create class from non-existing name](todo/actions.md#a41-create-class-from-non-existing-name) | Medium | Medium |

docs/wasm.md

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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

Comments
 (0)