Something went wrong. Try again.
Builds Windows Installer packages from WiX sources on Linux or Windows, in pure Zig; a clean-room reimplementation of msitools' wixl.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! The `zixl` command line. Its options follow those of msitools' `wixl`//! wherever zixl implements the feature behind them, and the ones it does not//! implement are refused by name rather than ignored. `--depfile`, which the//! `build.zig` step uses, is zixl's own.
const std = @import("std");const Io = std.Io;
const zixl = @import("zixl");
const usage = \\usage: zixl [options] <source.wxs>... \\ \\ -o, --output FILE write the installer package to FILE \\ (default: beside the source, as .msi) \\ -a, --arch ARCH x86, x64 or arm64 (default x64) \\ -D, --define NAME[=VALUE] \\ define $(var.NAME) for the preprocessor \\ -I, --includedir DIR search DIR for files <?include?> names \\ -E, --only-preproc print the preprocessed source and stop \\ -v, --verbose say what is being done \\ --depfile FILE write the files the package was made from to \\ FILE, as a Makefile rule (zixl's own option) \\ -h, --help show this help and exit \\ --version show the version and exit \\ \\SOURCE_DATE_EPOCH, when set, is the time recorded in the package. \\;
/// Options of wixl's that zixl does not implement yet, refused by name.const unsupported = [_][]const u8{ "--extdir", "--wxidir", "--ext" };
pub fn main(init: std.process.Init) !u8 { const arena = init.arena.allocator(); const io = init.io; const args = try init.minimal.args.toSlice(arena);
var stdout_buffer: [1024]u8 = undefined; var stdout_writer = Io.File.stdout().writer(io, &stdout_buffer); const stdout = &stdout_writer.interface; var stderr_buffer: [4096]u8 = undefined; var stderr_writer = Io.File.stderr().writer(io, &stderr_buffer); const stderr = &stderr_writer.interface;
var output: ?[]const u8 = null; var depfile: ?[]const u8 = null; var arch: zixl.Arch = .x64; var verbose = false; var only_preprocess = false; var defines: std.ArrayList(zixl.Define) = .empty; var include_dirs: std.ArrayList([]const u8) = .empty; var sources: std.ArrayList([]const u8) = .empty;
var i: usize = 1; while (i < args.len) : (i += 1) { const arg: []const u8 = args[i]; if (eql(arg, "-h") or eql(arg, "--help")) { try stdout.writeAll(usage); try stdout.flush(); return 0; } else if (eql(arg, "--version")) { try stdout.print("zixl {s}\n", .{zixl.version}); try stdout.flush(); return 0; } else if (eql(arg, "-v") or eql(arg, "--verbose")) { verbose = true; } else if (eql(arg, "-I") or eql(arg, "--includedir")) { i += 1; if (i == args.len) return fail(stderr, "{s} needs a directory", .{arg}); try include_dirs.append(arena, args[i]); } else if (std.mem.startsWith(u8, arg, "-I") or std.mem.startsWith(u8, arg, "--includedir=")) { try include_dirs.append(arena, if (arg[1] == 'I') arg[2..] else arg["--includedir=".len..]); } else if (eql(arg, "-E") or eql(arg, "--only-preproc")) { only_preprocess = true; } else if (eql(arg, "-D") or eql(arg, "--define") or std.mem.startsWith(u8, arg, "-D") or std.mem.startsWith(u8, arg, "--define=")) { // The definition follows as the next argument, or attached: // `-DNAME=VALUE` or `--define=NAME=VALUE`. const definition: []const u8 = if (eql(arg, "-D") or eql(arg, "--define")) blk: { i += 1; if (i == args.len) return fail(stderr, "{s} needs a definition", .{arg}); break :blk args[i]; } else if (arg[1] == 'D') arg[2..] else arg["--define=".len..]; const eq = std.mem.findScalar(u8, definition, '='); const name = if (eq) |e| definition[0..e] else definition; if (name.len == 0) return fail(stderr, "{s} needs a variable name", .{arg}); try defines.append(arena, .{ .name = name, .value = if (eq) |e| definition[e + 1 ..] else "" }); } else if (eql(arg, "--depfile") or std.mem.startsWith(u8, arg, "--depfile=")) { depfile = if (eql(arg, "--depfile")) blk: { i += 1; if (i == args.len) return fail(stderr, "{s} needs a file", .{arg}); break :blk args[i]; } else arg["--depfile=".len..]; } else if (valueOption(arg, &.{ "-o", "--output", "-a", "--arch" })) |name| { // The value follows as the next argument, or attached: `-oFILE` // or `--output=FILE`. const value: []const u8 = if (arg.len == name.len) blk: { i += 1; if (i == args.len) return fail(stderr, "{s} needs a value", .{arg}); break :blk args[i]; } else arg[name.len + @intFromBool(name[1] == '-') ..]; if (eql(name, "-o") or eql(name, "--output")) { output = value; } else { // wixl's name for x86 is taken too. arch = if (eql(value, "intel")) .x86 else std.meta.stringToEnum(zixl.Arch, value) orelse return fail(stderr, "unknown architecture \"{s}\"; zixl builds x86, x64 and arm64", .{value}); } } else if (optionName(arg)) |name| { for (unsupported) |u| { if (eql(name, u)) return fail(stderr, "{s} is not supported", .{name}); } return fail(stderr, "unknown option {s}", .{name}); } else { try sources.append(arena, arg); } }
if (sources.items.len == 0) { try stderr.writeAll(usage); try stderr.flush(); return 1; } const source = sources.items[0];
var disk: zixl.FileSource.Disk = .{ .io = io }; // Every file read is noted, for --depfile. var recording: zixl.FileSource.Recording = .{ .inner = disk.fileSource(), .gpa = arena }; var diags: zixl.Diagnostics = .init(arena); const current_dir = std.process.currentPathAlloc(io, arena) catch |e| return fail(stderr, "cannot find the current directory: {s}", .{@errorName(e)}); const environment: zixl.Environment = .fromMap(init.environ_map);
if (only_preprocess) { // Each source, preprocessed, one after another. var failed = false; for (sources.items) |path| { const text = disk.fileSource().read(arena, path) catch |e| return fail(stderr, "{s}: cannot be read: {s}", .{ path, @errorName(e) }); const pre = zixl.preprocess(arena, &diags, .{ .path = path, .text = text }, .{ .defines = defines.items, .environment = environment, .current_dir = current_dir, .arch = arch, .files = disk.fileSource(), .include_dirs = include_dirs.items, }) catch { failed = true; continue; }; try stdout.writeAll(pre.text); } try diags.render(stderr); try stderr.flush(); try stdout.flush(); return if (failed) 1 else 0; }
const out_path = output orelse try defaultOutput(arena, source);
const timestamp = if (init.environ_map.get("SOURCE_DATE_EPOCH")) |s| std.fmt.parseInt(i64, s, 10) catch return fail(stderr, "SOURCE_DATE_EPOCH is not a number of seconds: \"{s}\"", .{s}) else Io.Clock.real.now(io).toSeconds();
if (verbose) try stderr.print("zixl: building {s} for {s}, at {d} seconds after the epoch\n", .{ source, @tagName(arch), timestamp }); if (verbose) for (sources.items[1..]) |s| try stderr.print("zixl: with {s}\n", .{s});
const db = zixl.buildDatabase(arena, &diags, .{ .sources = sources.items, .files = recording.fileSource(), .arch = arch, .timestamp = timestamp, .defines = defines.items, .environment = environment, .current_dir = current_dir, .include_dirs = include_dirs.items, }) catch { try diags.render(stderr); try stderr.flush(); return 1; };
// Written to memory first, the package and any cabinets kept beside it, // so that a failure leaves nothing half-written behind. var bytes: Io.Writer.Allocating = .init(arena); var cabinets: Cabinets = .{ .arena = arena }; zixl.writeWithCabinets(arena, &diags, &db, &bytes.writer, cabinets.writer()) catch |e| switch (e) { error.Invalid => { try diags.render(stderr); try stderr.flush(); return 1; }, else => |other| return other, }; try diags.render(stderr); Io.Dir.cwd().writeFile(io, .{ .sub_path = out_path, .data = bytes.written() }) catch |e| return fail(stderr, "{s}: cannot be written: {s}", .{ out_path, @errorName(e) }); if (verbose) try stderr.print("zixl: wrote {s}, {d} bytes\n", .{ out_path, bytes.written().len }); for (cabinets.list.items) |cab| { const path = if (std.fs.path.dirname(out_path)) |dir| try std.fs.path.join(arena, &.{ dir, cab.name }) else cab.name; Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = cab.bytes }) catch |e| return fail(stderr, "{s}: cannot be written: {s}", .{ path, @errorName(e) }); if (verbose) try stderr.print("zixl: wrote {s}, {d} bytes\n", .{ path, cab.bytes.len }); } if (depfile) |path| { const rule = try dependencies(arena, current_dir, out_path, recording.paths.items); Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = rule }) catch |e| return fail(stderr, "{s}: cannot be written: {s}", .{ path, @errorName(e) }); } try stderr.flush(); return 0;}
/// The cabinets kept beside the package, held until the package is written.const Cabinets = struct { arena: std.mem.Allocator, list: std.ArrayList(struct { name: []const u8, bytes: []const u8 }) = .empty,
fn writer(c: *Cabinets) zixl.CabinetWriter { return .{ .context = c, .writeFn = add }; }
fn add(context: *anyopaque, name: []const u8, bytes: []const u8) anyerror!void { const c: *Cabinets = @ptrCast(@alignCast(context)); try c.list.append(c.arena, .{ .name = try c.arena.dupe(u8, name), .bytes = try c.arena.dupe(u8, bytes) }); }};
fn eql(a: []const u8, b: []const u8) bool { return std.mem.eql(u8, a, b);}
/// Which of `names` the argument is, alone or with its value attached, or/// null for none of them.fn valueOption(arg: []const u8, names: []const []const u8) ?[]const u8 { for (names) |name| { if (eql(arg, name)) return name; if (!std.mem.startsWith(u8, arg, name)) continue; // `--output=FILE`, but not `--outputs`; `-oFILE`. if (name[1] != '-' or arg[name.len] == '=') return name; } return null;}
/// The option an argument names, without any `=value`, or null for an/// argument that is not an option.fn optionName(arg: []const u8) ?[]const u8 { if (arg.len < 2 or arg[0] != '-') return null; // A short option with its value attached, as in `-DNAME=1`. if (arg[1] != '-') return arg[0..2]; if (std.mem.findScalar(u8, arg, '=')) |eq| return arg[0..eq]; return arg;}
/// The source's path with its extension replaced by `.msi`, so that the/// package lands beside its source, as wixl's does.fn defaultOutput(arena: std.mem.Allocator, source: []const u8) ![]const u8 { const base_start = source.len - std.fs.path.basename(source).len; const stem = if (std.mem.findScalarLast(u8, source[base_start..], '.')) |dot| source[0 .. base_start + dot] else source; return std.fmt.allocPrint(arena, "{s}.msi", .{stem});}
/// A Makefile rule saying that `target` was made from `paths`: what a build/// system reads to know when to make it again. Every path is made absolute,/// since whoever reads the rule need not share zixl's current directory.fn dependencies(arena: std.mem.Allocator, current_dir: []const u8, target: []const u8, paths: []const []const u8) ![]const u8 { var out: Io.Writer.Allocating = .init(arena); const w = &out.writer; try writeEscaped(w, try std.fs.path.resolve(arena, &.{ current_dir, target })); try w.writeAll(":"); for (paths) |p| { try w.writeAll(" \\\n "); try writeEscaped(w, try std.fs.path.resolve(arena, &.{ current_dir, p })); } try w.writeAll("\n"); return out.written();}
/// A path as a Makefile rule holds it, with its spaces escaped.fn writeEscaped(w: *Io.Writer, path: []const u8) !void { for (path) |c| { if (c == ' ') try w.writeByte('\\'); try w.writeByte(c); }}
fn fail(stderr: *Io.Writer, comptime fmt: []const u8, args: anytype) !u8 { try stderr.print("zixl: " ++ fmt ++ "\n", args); try stderr.flush(); return 1;}
test "options with values" { const names: []const []const u8 = &.{ "-o", "--output" }; try std.testing.expectEqualStrings("-o", valueOption("-o", names).?); try std.testing.expectEqualStrings("-o", valueOption("-oa.msi", names).?); try std.testing.expectEqualStrings("--output", valueOption("--output=a.msi", names).?); try std.testing.expect(valueOption("--outputs", names) == null);}
test "option names" { try std.testing.expectEqualStrings("-D", optionName("-DFOO=1").?); try std.testing.expectEqualStrings("--define", optionName("--define=FOO").?); try std.testing.expectEqualStrings("--ext", optionName("--ext").?); try std.testing.expect(optionName("a.wxs") == null); try std.testing.expect(optionName("-") == null);}
test "default output" { var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); try std.testing.expectEqualStrings("dir/product.msi", try defaultOutput(arena.allocator(), "dir/product.wxs")); try std.testing.expectEqualStrings("dir.v2/product.msi", try defaultOutput(arena.allocator(), "dir.v2/product"));}
test "dependencies" { var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); try std.testing.expectEqualStrings( "/work/out/product.msi: \\\n /work/product.wxs \\\n /files/Read\\ Me.txt\n", try dependencies(arena.allocator(), "/work", "out/product.msi", &.{ "product.wxs", "/files/Read Me.txt" }), );}