Skip to content

Replace nwnsc subprocess with WASM compiler for diagnostics#82

Closed
PhilippeChab wants to merge 8 commits into
mainfrom
wasm-diagnostics
Closed

Replace nwnsc subprocess with WASM compiler for diagnostics#82
PhilippeChab wants to merge 8 commits into
mainfrom
wasm-diagnostics

Conversation

@PhilippeChab
Copy link
Copy Markdown
Owner

Summary

PR #62's intent was to migrate from spawning the native `nwnsc` binary
to the in-process Beamdog `nwn_script_comp`. This PR accomplishes that
goal but uses a WebAssembly build of the compiler instead, removing
the need for any native subprocess.

The WASM build comes from a fork of `neverwinter.nim` extended with
multi-error recovery, no-entry-point validation, and a clean C API
suitable for Emscripten.

Why WASM over the native binary

  • Multi-error per pass. Reports every diagnostic in the file plus
    its `#include`'d helpers in a single compile, instead of stopping at
    the first error.
  • Include validation works without a `void main`. Helper /
    include scripts can be edited and validated as the user types.
  • One artifact, every platform. ~210KB WASM works anywhere Node
    runs. No separate Linux/Mac/Windows binaries to ship, no platform
    detection, no `shell: true` / `powershell.exe` quirks.
  • In-process. No `spawn` / `stdout` parsing, no per-keystroke
    process startup overhead.

Diff

3 files, +254 / -210:

  • `server/src/Providers/DiagnosticsProvider.ts` — rewritten:
    • Loads the WASM once on first publish.
    • Per publish, creates a compiler instance, sets
      `collect-all-errors` and `require-entry-point=false`, runs
      compile, drains the captured-error vector.
    • Resolver callback consults a per-call cache then asks the
      workspace file system for any name the compiler requests, so
      `nwscript.nss` and `#include`d files are picked up
      automatically.
    • Errors that fire inside an include get routed to that include's
      own URI when we can resolve it, so the squiggle lands on the file
      that actually contains the bug.
    • Pre-seeds empty diagnostic lists for the target plus all
      transitive includes, so fixing an error clears the squiggle
      immediately.
  • `server/wasm/nwscript_compiler.js`, `server/wasm/nwscript_compiler.wasm`
    — added (~210KB).

Removed

  • `spawn(child_process)` machinery
  • `getExecutablePath` / `hasSupportedOS` (the WASM is platform-neutral)
  • `nwnsc` flag construction (`-y -c -l -r -h -n -i`)
  • The line-by-line stdout parser

Kept (no-op for backwards compat with existing settings.json)

  • `compiler.os`
  • `compiler.nwnHome`
  • `compiler.nwnInstallation`
  • `compiler.reportWarnings`

Not touched in this PR

  • `server/resources/compiler/{linux,mac,windows}/nwnsc*` — the
    native binaries are no longer referenced but kept on the branch
    so this PR can be reverted cleanly. ~13MB of binaries can be
    deleted in a follow-up once the WASM approach is verified.

Test plan

  • `yarn compile:server` introduces no new errors.
  • `yarn buildServer` produces a working bundle.
  • Open a workspace with a broken `.nss`, confirm squiggles appear.
  • Multi-error: a file with three type errors on three separate
    lines should show three squiggles in one pass.
  • Include validation: editing a helper file (no `void main`)
    produces diagnostics directly, no longer requires the file to be
    imported by a main script.
  • Per-include error routing: an error in `lib.nss` reached via
    `#include "lib"` from `main.nss` shows on `lib.nss`.
  • Clean state: fixing an error clears the squiggle.

PR #62's intent was to migrate from spawning the native nwnsc binary
to the in-process Beamdog nwn_script_comp; this commit accomplishes
that goal but uses our WebAssembly build instead, which:

  - has multi-error recovery (one compile pass, every diagnostic in
    the file plus its #include'd helpers)
  - has no entry-point requirement (helper / include scripts can be
    edited and validated without a void main)
  - is ~210KB (vs 4-5MB native binary per platform)
  - works on every platform that runs Node, no separate Linux/Mac/
    Windows binaries to ship

DiagnosticsProvider rewritten:

  - Loads server/wasm/nwscript_compiler.{js,wasm} once on first use.
  - Per publish, creates a fresh compiler instance, sets
    collect-all-errors and require-entry-point=false, runs compile,
    drains the captured-error vector.
  - Resolver callback consults a per-call cache first then asks the
    workspace file system for any name the compiler requests, so
    nwscript.nss and #include'd files are picked up automatically.
  - Errors that fire inside an include are routed to the include's
    own URI (when we can resolve it via the workspace) rather than
    being attached to the file we were asked to check; the user
    sees the squiggle on the file that actually contains the bug.
  - Pre-seeds an empty diagnostic list for the target plus all
    transitive includes, so fixing an error clears the squiggle
    immediately.

Removed:

  - spawn(child_process) machinery
  - getExecutablePath / hasSupportedOS - the WASM is platform-neutral
  - nwnsc command-line flag construction (-y -c -l -r -h -n -i)
  - stdout/stderr line-by-line parser (replaced by the
    collected-errors API)

The compiler config keeps `enabled` and `verbose`; `os`, `nwnHome`,
`nwnInstallation`, `reportWarnings` are now no-ops (kept in
Config.ts for backwards-compat with any user settings.json that
sets them).

server/resources/compiler/{linux,mac,windows}/ binaries are no
longer referenced; left in place so this commit can be reverted
cleanly if the WASM approach hits a snag.
@PhilippeChab PhilippeChab marked this pull request as draft May 2, 2026 19:23
Drives the WASM compiler the same way DiagnosticsProvider does -
through cwrap signatures, the resolver-callback pattern, the
collect-all-errors / require-entry-point flags - so any breakage in
the bundle, the C exports, or the API contract surfaces here long
before it would in a real LSP session.

Covers: ABI version, clean compile, single error, multiple errors
per file, include-only validation, errors in #include'd files with
the [via:] trace, errors from include + main reported together,
file-not-found for unresolvable includes, determinism across
repeated compiles.

Run with: cd server && yarn test
The integration test spawns the bundled server.js as a child process
and speaks JSON-RPC over stdin/stdout exactly like VS Code does:
sends initialize, replies to workspace/configuration with the
default config, opens documents, waits for publishDiagnostics
notifications. Catches anything that breaks in real LSP usage that
the WASM unit test can't see - server boot, document indexing,
didOpen / didChange / didSave handling, the
connection.sendDiagnostics path, file-system resolution from a real
workspace.

Six scenarios covered:
  - clean file -> empty diagnostics
  - broken file -> type-error diagnostic with severity=Error
  - multi-error file -> three diagnostics in one pass
  - include-only file -> empty diagnostics (no entry-point required)
  - edit + save -> diagnostics clear
  - error in #include'd file -> some diagnostic surfaces

Bug surfaced and fixed while writing the test:

DiagnosticsProvider had `__dirname/../../wasm` for the WASM path.
That path was correct for the unbundled ts source layout but wrong
for the bundled server.js, which is at server/out/server.js -
__dirname/../../wasm goes one level too high and produces
"Cannot find module" at runtime. Changed to `__dirname/../wasm`
which is correct for the bundled layout (server/out -> server/wasm)
that VS Code actually runs.

Run with: cd server && yarn test
Verbose server logs: LSP_VERBOSE=1 cd server && yarn test
.vscodeignore broad-excludes server/** and re-includes specific
paths, but server/wasm wasn't in the re-include list. Result: the
packaged extension was missing the WASM compiler entirely - the
installed v3.0.1 logs "Cannot find module 'server/wasm/nwscript_
compiler.js'" on every save and produces no diagnostics. Adding
server/wasm to the un-ignore list fixes future packages.
Helper / include files now get their semantic errors reported in
multi-error mode. Previously the compiler skipped the tree walk
entirely when no entry point was found, so a user editing a helper
function would see no diagnostics on type errors.
The resolver callback freed the previous delivery buffer before
allocating a new one, then pushed each allocation onto an `owned`
list. At end of compile, every entry in `owned` was freed - so
every buffer except the last got freed twice. The corrupted heap
state didn't surface inside the same compile; it manifested as
"memory access out of bounds" one or two compiles later, when
malloc returned a bad pointer and a subsequent write trampled
something it shouldn't.

Reported on cmds_player.nss with a chained #include "inc_roles"
-> consts_roles -> consts_module: each save of a .nss file in the
workspace would resolve a few includes, leak double-frees, and
eventually crash.

Fix: drop the owned list. Free only the most recent delivery
buffer at the end of compile, since each loadCb call already
freed the previous one before allocating its own.

Added a regression test that compiles a 4-file include chain ten
times in a row and asserts no crash + no diagnostics. Without the
fix, this surfaces the heap corruption reliably by iter 3-4.
The old nwnsc-based diagnostics handed -h / -n flags to the binary,
which then read scripts directly out of nwn_base.bif and friends.
The WASM build can't open BIF archives, but Beamdog also ships the
same scripts extracted under data/base_scripts/ and ovr/. Those
directories are what the existing compiler.nwnInstallation /
compiler.nwnHome settings already point at, so the resolver now
walks them as a fallback when a script isn't in the workspace.

Indexed once on first miss and cached for the life of the LSP
process. The user-visible effect: #include "x0_i0_stringlib" and
the rest of the stock NWN library now resolve, so files that depend
on them (most real NWN modules) actually validate instead of
bailing with "FILE NOT FOUND".

The override directory under nwnHome is also indexed so per-game
local edits show up. Workspace files still take precedence over
both - if you've copied a stock script into your workspace and
modified it, the workspace copy wins.

Reload the LSP window after changing the NWN paths in settings;
the index is built once per process.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant