-
Notifications
You must be signed in to change notification settings - Fork 3
Lua interpreter
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.
We'll start by creating an Electron-Vite boilerplate app:
npm create @quick-start/electron@latest
Need to install the following packages:
@quick-start/[email protected]
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:
Then click the "Browse files" button:
Then click the "Code" button, right-click on "Download ZIP", and select "Copy link address":
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.
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:
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
And Mac OS:
npm run build:mac --x64
npm run build:mac --arm64
Note: You can only create a DMG install package on a Mac.
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.