Skip to content
Zig and WASM: your best friend here is the compiler

Zig and WASM: your best friend here is the compiler

Published: at 13:37

Some days ago, MJ Grzymek published an interesting piece on his blog about how Zig can be compiled in WASM and used for efficient web development. It reminded me I want to write about Zig and WASM for months.

Not because I’m in love with this language, I find it messy (and I’m not a low level guy). But its compiler is dope! Notably, it allows you to compile C/C++ code to WASM/WASI. And that could be a game changer!

Zig compiler can compile C/C++ too

Once installed, zig help command will show you this message:

info: Usage: zig [command] [options]

Commands:

  build            Build project from build.zig
  init-exe         Initialize a `zig build` application in the cwd
  init-lib         Initialize a `zig build` library in the cwd

  ast-check        Look for simple compile errors in any set of files
  build-exe        Create executable from source or object files
  build-lib        Create library from source or object files
  build-obj        Create object from source or object files
  fmt              Reformat Zig source into canonical form
  run              Create executable and run immediately
  test             Create and run a test build
  translate-c      Convert C code to Zig code

  ar               Use Zig as a drop-in archiver
  cc               Use Zig as a drop-in C compiler
  c++              Use Zig as a drop-in C++ compiler
  ...

As you can see here, zig can be used as a drop-in C/C++ compiler.

For example, you can create a guess.c file:

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int main() {
    int number, guess, attempts = 0;
    srand(time(NULL));
    number = rand() % 421 + 1;

    printf("Guess a number between 1 and 421\n");
    do {
        scanf("%d", &guess);
        attempts++;
        if (guess > number) {
            printf("Lower number please!\n");
        } else if (guess < number) {
            printf("Higher number please!\n");
        } else {
            printf("You guessed %d it in %d attempts\n", number, attempts);
        }
    } while (guess != number);

    return 0;
}

Then compile it with zig:

zig cc guess.c -o guess
./guess

And it works! You can do the same with a C++ code in an analyze.cpp file:

#include <string>
#include <sstream>
#include <iostream>

int main() {
    std::string line;
    int lineCount = 0, charCount = 0, charNoSpacesCount = 0, wordCount = 0;

    while (std::getline(std::cin, line)) {
        lineCount++;
        charCount += line.length();

        for (char c : line) {
            if (c != ' ') {
                charNoSpacesCount++;
            }
        }

        std::stringstream ss(line);
        std::string word;
        while (ss >> word) {
            wordCount++;
        }
    }

    // Display results, formatted as JSON
    std::cout << "{" << std::endl;
    std::cout << "  \"Line count\": " << lineCount << "," << std::endl;
    std::cout << "  \"Character count (including spaces)\": " << charCount << "," << std::endl;
    std::cout << "  \"Character count (excluding spaces)\": " << charNoSpacesCount << "," << std::endl;
    std::cout << "  \"Word count\": " << wordCount << std::endl;
    std::cout << "}" << std::endl;

    return 0;
}

Compile it with zig and run it:

zig c++ analyze.cpp -o analyze
./analyze analyze.cpp | jq

It will show you the number of lines, characters, words, JSON formatted.

Compile (some of) your C/C++ code to WASM/WASI

But the zig compiler also have a -target option, which can be used to compile previous C/C++ code to WASM/WASI with no changes.

Thus, it can run on any platform with a WASM runtime, such as Wasmtime:

zig cc -target wasm32-wasi guess.c -o guess.wasm
zig c++ -target wasm32-wasi analyze.cpp -o analyze.wasm
wasmtime guess.wasm
wasmtime analyze.wasm < analyze.cpp | jq

Note there are some limitations, as I wasn’t able to manipulate files. It’s why I relied on stdin in the previous example. So, the following C++ code compiles and runs, but not with the wasm32-wasi target:

#include <iostream>
#include <fstream>

int main(int argc, char* argv[]) {

    std::ifstream file(argv[1]);
    if(!file.is_open()) {
        std::cerr << "Error: could not open file\n";
        return 1;
    }

    std::string line;
    while(std::getline(file, line)) {
        std::cout << line << '\n';
    }

    file.close();
    return 0;
}

The following C code compiles in WASM, but doesn’t run, I get a could not open file error:

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char* argv[]) {

    FILE *file = fopen(argv[1], "r");
    if(file == NULL) {
        fprintf(stderr, "Error: could not open file\n");
        return 1;
    }

    char *line = NULL;
    size_t len = 0;
    ssize_t read;

    while((read = getline(&line, &len, file)) != -1) {
        printf("%s", line);
    }

    free(line);
    fclose(file);

    return 0;
}

As WASM/WASI (preview2 was recently announced) and zig compiler evolves, be sure it will be possible to do more and more things.

What about V?

I couldn’t end without trying to compile V code to WASM through zig. For the record, V compiler supports multiple backends (c, go, js, js_browser, js_node, js_freestanding) and wasm. Thus, the following hello.v file:

fn main() {
    println('Hello, WASM!')
}

Can be compiled to WASM:

v -backend wasm hello.v -o hello.wasm
wasmtime hello.wasm

But this doesn’t work with modules. For example, this analyze.v file:

import os
import json { encode }

struct Stats {
mut:
	line_count            int
	char_count            int // Including spaces
	char_no_spaces_count  int // Excluding spaces
	word_count            int
}

fn main() {
	mut stats := Stats{}

	for line in os.get_lines() {
		stats.line_count++
		stats.char_count += line.len
		stats.char_no_spaces_count += line.replace(' ', '').len

		words := line.split(' ').filter(it != '')
		stats.word_count += words.len
	}

	// Serialize `stats` to JSON
	json_str := encode(stats)
	println(json_str)
}

Compiles well in V, but not with a wasm backend. I tried with the c backend and then with zig cc… it leads to errors.

Go: the good balance for WASM/WASI?

What’s important here, is to see how WASM/WASI is gaining traction in multiple languages. Rust supports it as a target for a long time, there are movements from languages such as OCaml, Python, Ruby, etc.

Since last summer, and the 1.21 release, Go compiler natively supports WASM/WASI backend. And it works pretty well, it’s my personal choice for multiples WASM projects, easy to bootstrap.

For example, this analyze.go file:

package main

import (
	"bufio"
	"encoding/json"
	"fmt"
	"os"
	"strings"
)

type Statistics struct {
	LineCount             int `json:"Line count"`
	CharacterCount        int `json:"Character count (including spaces)"`
	CharacterCountNoSpace int `json:"Character count (excluding spaces)"`
	WordCount             int `json:"Word count"`
}

func main() {
	scanner := bufio.NewScanner(os.Stdin)

	var stats Statistics

	for scanner.Scan() {
		line := scanner.Text()
		stats.LineCount++
		stats.CharacterCount += len(line)
		stats.CharacterCountNoSpace += len(strings.ReplaceAll(line, " ", ""))
		stats.WordCount += len(strings.Fields(line))
	}

	if err := scanner.Err(); err != nil {
		fmt.Fprintf(os.Stderr, "reading standard input: %v", err)
	}

	jsonData, err := json.MarshalIndent(stats, "", "  ")
	if err != nil {
		fmt.Fprintf(os.Stderr, "error marshalling stats to JSON: %v", err)
		return
	}

	fmt.Println(string(jsonData))
}

Compiles and runs well in WASM:

GOOS=wasip1 GOARCH=wasm go build -o analyze.wasm analyze.go
wasmtime analyze.wasm < analyze.go | jq

Note that the built WASM file is 2.5MB, compared to 3.3/3.4MB with zig from C/C++. The binary was 48K in native C++, 177K in native V with JSON module.