Skip to content

Lua interpreter

Chung Leong edited this page Aug 6, 2024 · 17 revisions

In this example we're going to build a Lua interpreter. The main purpose is to demonstrate how to use the Zig package manager to include third-party code in a project. Electron is going to be our JavaScript platform. We'll be using React for our app's interface and Electron-vite as the build tool.

Creating the app

We'll start by creating an Electron-Vite boilerplate app:

npm create @quick-start/electron@latest
Need to install the following packages:
@quick-start/create-electron@1.0.23
Ok to proceed? (y)
✔ Project name: … lua
✔ Select a framework: › react
✔ Add TypeScript? … [No] / Yes
✔ Add Electron updater plugin? … [No] / Yes
✔ Enable Electron download mirror proxy? … [No] / Yes

We'll then add the node-zigar module:

cd lua
npm install
npm install node-zigar

After that we'll install ziglua, a Zig package that provides the Lua language engine. As there is currently no central repository for Zig packages, you'll need to obtain ziglua from the source. First, go to the project's Github page. Click on the SHA of the last commit:

Github - ziglua

Then click the "Browse files" button:

Github - ziglua

Then click the "Code" button, right-click on "Download ZIP", and select "Copy link address":

Github - ziglua

Go back to the terminal, create the sub-directory zig, and cd to it:

mkdir zig
cd zig

Create an empty build.zig:

touch build.zig

Enter "zig fetch --save " then paste the copied URL and press ENTER:

zig fetch --save https://github.com/natecraddock/ziglua/archive/486f51d3acc61d805783f5f07aee34c75ab59a25.zip

If you're still using Zig 0.12.0, you would need to replace the .zip extension with .tar.gz:

zig fetch --save https://github.com/natecraddock/ziglua/archive/486f51d3acc61d805783f5f07aee34c75ab59a25.tar.gz

That'll fetch the package and create a build.zig.zon listing it as a dependency. We'll then fill in build.zig:

const std = @import("std");
const cfg = @import("./build-cfg.zig");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});
    const lib = b.addSharedLibrary(.{
        .name = cfg.module_name,
        .root_source_file = .{ .cwd_relative = cfg.stub_path },
        .target = target,
        .optimize = optimize,
    });
    const ziglua = b.dependency("ziglua", .{
        .target = target,
        .optimize = optimize,
    });
    const imports = .{
        .{ .name = "ziglua", .module = ziglua.module("ziglua") },
    };
    const mod = b.createModule(.{
        .root_source_file = .{ .cwd_relative = cfg.module_path },
        .imports = &imports,
    });
    mod.addIncludePath(.{ .cwd_relative = cfg.module_dir });
    lib.root_module.addImport("module", mod);
    if (cfg.use_libc) {
        lib.linkLibC();
    }
    const wf = switch (@hasDecl(std.Build, "addUpdateSourceFiles")) {
        true => b.addUpdateSourceFiles(),
        false => b.addWriteFiles(),
    };
    wf.addCopyFileToSource(lib.getEmittedBin(), cfg.output_path);
    wf.step.dependOn(&lib.step);
    b.getInstallStep().dependOn(&wf.step);
}

In the same directory create lua.zig:

const std = @import("std");
const ziglua = @import("ziglua");

var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
const LuaOpaque = opaque {};
const LuaOpaquePtr = *align(@alignOf(ziglua.Lua)) LuaOpaque;

pub fn createLua() !LuaOpaquePtr {
    const lua = try ziglua.Lua.init(&allocator);
    lua.openLibs();
    return @ptrCast(lua);
}

pub fn runLuaCode(opaque_ptr: LuaOpaquePtr, code: [:0]const u8) !void {
    const lua: *ziglua.Lua = @ptrCast(opaque_ptr);
    try lua.loadString(code);
    try lua.protectedCall(0, 0, 0);
}

pub fn freeLua(opaque_ptr: LuaOpaquePtr) void {
    const lua: *ziglua.Lua = @ptrCast(opaque_ptr);
    lua.deinit();
}

createLua() creates an instance of the Lua interpreter. The interpreter is returned as an opaque pointer so that implementation details are hidden from the JavaScipt side.runLuaCode() makes it run the given code, casting the opaque pointer it receives back into *Lua first. freeLua() frees memory allocated for the interpreter.

To test that our Zig code is working as expected, insert the following code into src/main/index.js:

require('node-zigar/cjs')
const { createLua, freeLua, runLuaCode } = require('../../zig/lua.zig')

const lua = createLua()
runLuaCode(lua, 'print "Hello world"')
freeLua(lua)

Note: Do not try to translate the require statements above into ESM import statements. Electron-Vite would just muck everything up when it translates them back into require statements again.

Start Electron-Vite in dev mode

npm run dev

A message should appear informing you that the "lua" module is being compiled. The Electron window will open when that is finished. Behind it, in the terminal window, "Hello world" should appear. That verifies that the interpreter is working.

Electron boilerplate app

Now let us provide a proper UI for our interpreter. First, we'll move the freeLua() call to a more appropriate place:

app.on('quit', () => freeLua(lua))

Then we're going to make runLuaCode() accessible from the frontend. Replace the following line:

  ipcMain.on('ping', () => console.log('pong'))

with

  ipcMain.on('run', (_, code) => runLuaCode(lua, code))

Finally, we need to redirect text written to stdout to the frontend. In createWindow(), add the following line at the bottom:

  __zigar.connect({ log: line => mainWindow.webContents.send('log', line) })

And __zigar to the list of symbols obtain from the module:

const { __zigar, createLua, freeLua, runLuaCode } = require('../../zig/lua.zig')

With the basic plumbing complete, we'll move onto the React frontend. Open src/renderer/src/App.jsx and replace its content with the following:

import { useCallback, useEffect, useRef, useState } from 'react';

function App() {
  const [ code, setCode ] = useState('')
  const [ output, setOutput ] = useState('')
  const linesRef = useRef([])
  useEffect(() => {
    window.electron.ipcRenderer.on('log', (_, text) => {
      const lines = linesRef.current
      lines.push(...text.split('\n'))
      while (lines.length > 200) {
        lines.shift()
      }
      setOutput(lines.join('\n') + '\n')
    })
    return () => window.electron.ipcRenderer.removeAllListeners('log')
  }, [])
  const onRunClick = useCallback(evt => window.electron.ipcRenderer.send('run', code), [ code ])
  const onCodeChange = useCallback(evt => setCode(evt.target.value), [])

  return (
    <>
      <div className="code-section">
        <textarea value={code} onChange={onCodeChange}/>
        <button onClick={onRunClick}>Run</button>
      </div>
      <div className="output-section">
        <textarea value={output} readOnly={true} />
      </div>
    </>
  )
}

export default App

Our UI is basically two textareas and a button. When the component mounts, we listen for the log event from the backend. When the button is clicked, we send the run event to the backend.

We still need CSS classes for our UI. Open src/renderer/src/assets/main.css and replace its content with the following:

@import './base.css';

body {
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  background-image: url('./wavy-lines.svg');
  background-size: cover;
  user-select: none;
  box-sizing: border-box;
}

#root {
  position: absolute;
  left: 0.5em;
  top: 0.5em;
  right: 0.5em;
  bottom: 0.5em;
  display: flex;
  flex-direction: column;
}

.code-section {
  flex: 0 0 10em;
  display: flex;
  flex-direction: row;
  width: 100%;
  padding: 0.5em 0.5em 0.5em 0.5em;
}

.code-section TEXTAREA {
  flex: 1 1 auto;
  background-color: transparent;
  color: #ffffff;
  font-family: 'Courier New', Courier, monospace;
  white-space: pre;
}

.code-section BUTTON {
  flex: 0 0 auto;
  font-size: 1.5em;
  font-weight: bold;
  text-transform: uppercase;
  padding: 0 1em 0 1em;
}

.output-section {
  flex: 1 1 auto;
  display: flex;
  flex-direction: row;
  padding: 0.5em 0.5em 0.5em 0.5em;
}

.output-section TEXTAREA {
  flex: 1 1 auto;
  background-color: transparent;
  color: #cccccc;
  font-family: 'Courier New', Courier, monospace;
  white-space: pre;
}

One final detail is the window title. Open src/renderer/index.html and change the <title> tag:

    <title>Lua interpretor</title>

And here's the app running a code example from Wikipedia:

Interpretor

Configuring the app for deployment

Because we're using Electron-Vite in this project, the deployments steps are somewhat different from previous examples. The basic ideas are the same though. First, we need to change our require statement so it loads a .zigar instead:

const { __zigar, createLua, freeLua, runLuaCode } = require('../lib/lua.zigar')

Since our app is running from the out/main sub-directory, we need to put our library files in out/lib. This is the node-zigar.config.json we need:

{
  "optimize": "ReleaseSmall",
  "sourceFiles": {
    "out/lib/lua.zigar": "zig/lua.zig"
  },
  "targets": [
    { "platform": "win32", "arch": "x64" },
    { "platform": "win32", "arch": "arm64" },
    { "platform": "win32", "arch": "ia32" },
    { "platform": "linux", "arch": "x64" },
    { "platform": "linux", "arch": "arm64" },
    { "platform": "darwin", "arch": "x64" },
    { "platform": "darwin", "arch": "arm64" }
  ]
}

Run the following command to generate library files for the desired platforms:

npx node-zigar build

The project is set up to use Electron-Builder. We need to make certain changes to electron-builder.yml. First add the following rules to the files section:

files:
  #  ...
  - '!zig/*'
  - '!zig-cache/*'
  - '!node-zigar.config.json'

Then add an additional rule to the asarUnpacked section:

asarUnpack:
  #  ...
  - out/lib/**

And finally, because we want to create packages supporting different CPU architectures, we want the arch variable in the package name:

nsis:
  artifactName: ${name}-${version}-${arch}-setup.${ext}
dmg:
  artifactName: ${name}-${version}-${arch}.${ext}
appImage:
  artifactName: ${name}-${version}-${arch}.${ext}

To build for Linux, run the following commands:

npm run build:linux --x64
npm run build:linux --arm64

For Windows:

npm run build:win --ia32
npm run build:win --x64
npm run build:win --arm64

Example running on Windows 10

And Mac OS:

npm run build:mac --x64
npm run build:mac --arm64

Example running on Mac OS

Note: You can only create a DMG install package on a Mac.

Conclusion

Zig's package manager makes it incredibly easy to make use of C libraries. While this example ultimately relies on the Lua C API, at no point did you need to think about it. There was no autoconf script to run. You didn't need to build any static library. All you had to do is ask Zig to fetch the module from the right URL. Work done by the maintainer of ziglua took care of everything, including issues related to cross-platform support. Using a Zig package is almost as easy as using something from npm.

The Zig package manager is still under heavy development. Currently there aren't so many ready-to-use packages and is no central package directory where you can quickly find something you need. I hope you can see the ease-of-use that it promises though. The ability to easily use native code makes Electron a much more powerful platform.

Clone this wiki locally