WebAssembly
A ghūl program can be compiled to WebAssembly as well as to .NET. The compiler writes a WasmGC module and a small JavaScript loader beside it. The program runs under Node.js 22.19 or later on the 22 line, 24.15 or later on the 24 line, or 25 or later, or in a browser that supports WasmGC. Node 23 can't run it. The same source builds for the native target as well.
work in progress
The WebAssembly target doesn't yet compile the whole language, and it has ghūl's own libraries in place of .NET's. What works today lists what is missing. The WebAssembly epic tracks it.
installing
The ghul command builds WebAssembly programs. On Linux, x64 or arm64, it installs without .NET:
curl -fsSL https://raw.githubusercontent.com/ghul-lang/ghul-cli/main/install.sh | shWhere the .NET 10 SDK is installed, ghul is also available as the ghul.cli .NET tool, which is the install route on macOS and Windows:
dotnet tool install -g ghul.clighul fetches its compiler the first time it builds, and ghul install compiler fetches it ahead of time. Tools are always the newest release: ghul update brings ghul, the compiler and Node.js up to date. Running the result needs one of the Node.js releases above. ghul install node fetches one, and ghul run says so when the Node.js it finds is older.
hello world
A WebAssembly program is a project: a directory holding a ghul-project.json manifest that names the project, its targets and its sources. ghul new creates one, and WebAssembly is its default target:
ghul new hello
cd helloThe new directory holds the manifest:
{
"name": "hello",
"kind": "program",
"targets": ["wasm"],
"sources": ["src/**/*.ghul"],
"tools": { "compiler": ">=66.11.0" },
"dependencies": {
"ghul-core": { "version": ">=1.0.0" },
"ghul-runtime": { "version": ">=29.2.0" }
}
}a .gitignore for the build output in out/, and the program, as src/main.ghul:
use IO.Std.write_line
entry() is
write_line("hello")
siThe manifest declares the two libraries a WebAssembly build compiles in. Each takes a version range: >= is a floor that admits newer releases, ^1.0.0 stays within version 1, and a bare 1.0.0 is exactly that release. ghul new writes the releases in use when it ran as floors.
ghul run builds the project into out/wasm/ and runs it:
ghul run # runs out/wasm/hello.mjs under Node.jsThe first build installs the compiler if there isn't one, and fetches the libraries. Later builds reuse them. Each file in a project imports what it calls with use, or starts with use default for write_line, the pipes and the collections.
ghul build builds without running. The ghul-cli README covers the manifest in full.
what works today
With the latest compiler, a WebAssembly build compiles classes, structs, traits and unions, with virtual and trait calls, case over a union, generics, arrays, tuples, function values and closures, strings and string interpolation with alignment and formats, bigint, the scalar conversions such as double(n), list comprehensions, slicing, throw, try, catch and finally, generators, asynchronous functions and await, reading standard input, and for loops over ranges.
Not yet:
decimal- an interpolated number formatted other than by the standard formats
D,E,F,G,N,RandXor a custom pattern of0,#,.and,, and an interpolated enum given any format - reflection beyond a subset: a program that reflects on its types is built with
--rtti, and without it reflection throwsNotSupportedException
A WebAssembly program uses ghūl's own libraries, and the .NET libraries aren't available: there are no regular expressions, HTTP, sockets or culture-aware formatting, and a program can't call a library that already exists. A program run by Node reads and writes the real file system. In a browser there is no file system to reach, so File and Directory work on one held in memory, which starts empty. A program can call JavaScript only through @extern: each function it declares is looked up in the externs object the script that loads the module passes to the loader's run, and a program that declares one the script doesn't supply fails to start.
The WebAssembly epic tracks what is still to come. Run ghul update to pick up each release as it adds to the list.
An exception nothing catches ends the program with Unhandled exception., its type and its message. A build with --debug also lists the functions the exception was thrown through, innermost first, one per line: ghul run --target wasm --debug. A build without --debug throws more cheaply, and its report lists no frames. An error the WebAssembly engine raises itself, such as dividing an integer by zero, lists its frames either way.
the same program on .NET
The program above is ordinary ghūl, and the .NET target builds it into an ordinary .NET executable, with the .NET SDK and a .ghulproj project rather than ghul. WebAssembly has no .NET base class library. Two libraries compiled into the module from source take its place: ghul-core, which gives the built-in types their members and declares the collections, and ghul-runtime, which supplies the pipes and the rest of Ghul. A program that calls .NET APIs directly builds only for .NET, unless it keeps those calls to the .NET build.
Strings, numbers and dates behave as they do in a .NET program configured for invariant globalization: strings compare and sort by UTF-16 code unit, and numbers and dates format the same way whatever the culture of the machine. A .NET program gets the same behaviour by setting <InvariantGlobalization>true</InvariantGlobalization> in its project.
code for one target
@IF.dotnet() written before a definition or a statement keeps it only in a .NET build, @IF.wasm() keeps it only in a wasm build, and @IF.llvm() keeps it only in a native build. That gives each target a version of its own where they need to differ:
use IO.Std.write_line
entry() is
write_line(target_name())
@IF.wasm()
write_line("a statement kept only for wasm")
si
@IF.dotnet()
target_name() -> string => "dotnet"
@IF.wasm()
target_name() -> string => "wasm"@IF.not.wasm() keeps what it marks everywhere except a wasm build. Whatever a build leaves out is never compiled, so a @IF.dotnet() declaration can call a .NET API the wasm target doesn't have. To leave a whole file out of one target, mark its namespace.
The same pragma works with names of your own, which the compiler's --define <name> option turns on. A name nothing turns on is off, and dotnet, wasm and llvm are always decided by the target being built.
errors
A call to a .NET API that the core library doesn't declare is reported the way any other missing name is:
main.ghul: 11,23..11,34: error: member Environment not found in SystemSomething the WebAssembly target can't support at all is reported where it's written, such as a pointer type or an IL. pragma:
main.ghul: 20,15..20,22: error: pointer types not supported on the wasm target
main.ghul: 4,6..4,13: error: pragma IL.name not supported on the wasm targetSomething the target will support but can't compile yet is reported at the function holding it:
main.ghul: 3,1..3,6: error: code generation for a decimal literal is not supported on the wasm target