// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! WiX source text in, preprocessed text or diagnostics out: the behavior of //! the preprocessor, checked through the library's public interface. const std = @import("std"); const testing = std.testing; const zixl = @import("zixl"); const Result = struct { arena: std.heap.ArenaAllocator, diags: zixl.Diagnostics, text: ?[]const u8, fn deinit(r: *Result) void { r.arena.deinit(); testing.allocator.destroy(r); } fn reported(r: *const Result, needle: []const u8) bool { var buf: [8192]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); r.diags.render(&w) catch return false; return std.mem.find(u8, w.buffered(), needle) != null; } }; const Env = struct { fn lookup(_: ?*const anyopaque, name: []const u8) ?[]const u8 { if (std.mem.eql(u8, name, "HOME_ISH")) return "/home/someone"; return null; } }; fn run(text: []const u8, defines: []const zixl.Define) !*Result { const r = try testing.allocator.create(Result); r.arena = .init(testing.allocator); r.diags = .init(r.arena.allocator()); const out = zixl.preprocess(r.arena.allocator(), &r.diags, .{ .path = "dir/product.wxs", .text = text }, .{ .defines = defines, .environment = .{ .lookupFn = Env.lookup }, .current_dir = "/build", .arch = .arm64, }) catch null; r.text = if (out) |o| o.text else null; return r; } fn expectOutput(input: []const u8, defines: []const zixl.Define, expected: []const u8) !void { const r = try run(input, defines); defer r.deinit(); try testing.expectEqualStrings(expected, r.text.?); } test "text without directives passes through untouched" { const text = "\n\n \n \n\n"; try expectOutput(text, &.{}, text); } test "define, then use" { try expectOutput( \\ , &.{}, \\ ); } test "a define's value can use other variables" { try expectOutput("$(var.B)", &.{}, "xy"); } test "a value is escaped for where it lands" { try expectOutput( \\ "d" 'e'" ?>$(var.V) , &.{}, \\a & b < c > "d" 'e' ); } test "undef forgets a variable, and a later define replaces it" { try expectOutput("$(var.A)$(var.A)", &.{}, "12"); } test "command-line defines are visible, and a later define replaces them" { try expectOutput("$(var.M)$(var.M)", &.{.{ .name = "M", .value = "cmdline" }}, "cmdlinesource"); } test "environment variables" { try expectOutput("", &.{}, ""); } test "system variables, the directories ending with a separator" { const sep = std.fs.path.sep_str; try expectOutput( "$(sys.CURRENTDIR)|$(sys.SOURCEFILEPATH)|$(sys.SOURCEFILEDIR)|$(sys.BUILDARCH)", &.{}, "/build" ++ sep ++ "|/build" ++ sep ++ "dir/product.wxs|/build" ++ sep ++ "dir" ++ sep ++ "|arm64", ); } test "$$( is a literal $(" { try expectOutput("", &.{}, ""); } test "the XML declaration and other instructions pass through" { try expectOutput("", &.{}, ""); } // -- what is reported -------------------------------------------------------- test "an undefined variable is an error naming it and where it is" { const r = try run("\n \n", &.{}); defer r.deinit(); try testing.expect(r.text == null); try testing.expect(r.reported("dir/product.wxs:2:9: error: the variable $(var.Missing) is not defined")); } test "an unset environment variable is an error" { const r = try run("$(env.NOT_SET)", &.{}); defer r.deinit(); try testing.expect(r.reported("1:1: error: the environment variable $(env.NOT_SET) is not set")); } test "a reference with no kind" { const r = try run("$(Name)", &.{}); defer r.deinit(); try testing.expect(r.reported("$(Name) names no kind of variable")); } test "an unknown system variable" { const r = try run("$(sys.PLATFORM)", &.{}); defer r.deinit(); try testing.expect(r.reported("$(sys.PLATFORM) is not a system variable")); } test "an unclosed reference" { const r = try run("x $(var.A", &.{}); defer r.deinit(); try testing.expect(r.reported("1:3: error: a variable reference is not closed")); } test "a define without a value defines it as empty" { try expectOutput("[$(var.A)]defined", &.{}, "[]defined"); } test "a define of something that is not a name" { const r = try run("", &.{}); defer r.deinit(); try testing.expect(r.reported("\"1x\" is not a variable name")); } test "undef of something never defined" { const r = try run("", &.{}); defer r.deinit(); try testing.expect(r.reported(": the variable is not defined")); } test "error stops the build where it is written" { const r = try run("\n\n ", &.{}); defer r.deinit(); try testing.expect(r.text == null); try testing.expect(r.reported("dir/product.wxs:3:3: error: Version 2 is too old")); } test "warning is reported and the source goes on" { const r = try run("ok", &.{}); defer r.deinit(); try testing.expectEqualStrings("ok", r.text.?); try testing.expect(r.reported("dir/product.wxs:1:1: warning: careful")); } test "an unclosed processing instruction" { const r = try run(" \\ \\ \\ \\ \\ \\ }} }; try testing.expectError(error.Invalid, zixl.buildDatabase(arena.allocator(), &diags, .{ .sources = &.{"p.wxs"}, .files = files.fileSource(), .timestamp = 0, })); var buf: [1024]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try diags.render(&w); // `Bogus` is at column 120 of line 4 in the source as written, whatever // the substitution before it did to the preprocessed text. try testing.expect(std.mem.find(u8, w.buffered(), "p.wxs:4:120: error: attribute Bogus of is not supported") != null); } // -- conditionals ----------------------------------------------------------- const ab = [_]zixl.Define{ .{ .name = "A", .value = "1" }, .{ .name = "B", .value = "good VAR" } }; test "if, elseif, else" { const t = "twooneother"; try expectOutput(t, &ab, "one"); try expectOutput(t, &.{.{ .name = "A", .value = "2" }}, "two"); try expectOutput(t, &.{.{ .name = "A", .value = "3" }}, "other"); } test "ifdef and ifndef" { try expectOutput("yesno|yesno", &ab, "yes|no"); try expectOutput("yesno|yesno", &ab, "no|yes"); } test "nested conditionals, including inside a branch not taken" { try expectOutput( "[inner-xinner-else]never", &ab, "[inner-else]", ); } test "the comparison operators, as WiX documents them" { const cases = [_]struct { []const u8, bool }{ .{ "$(var.A) = 1", true }, .{ "$(var.A) = \"1\"", true }, .{ "$(var.A) != 1", false }, .{ "$(var.B) = \"good VAR\"", true }, .{ "$(var.B) = \"GOOD var\"", false }, .{ "$(var.B) ~= \"GOOD var\"", true }, .{ "3 < $(var.A)", false }, .{ "$(var.A) < 3", true }, .{ "$(var.A) <= 1", true }, .{ "$(var.A) >= 2", false }, .{ "$(var.A) > 0", true }, .{ "not \"x\"= \"y\"", true }, .{ "$(var.A)", true }, .{ "$(var.Missing)", false }, .{ "$(var.A) and not $(var.Missing)", true }, .{ "$(var.A) Or ($(var.B) And $(var.A) >=3)", true }, .{ "$(var.Missing) or $(var.A) = 1 AND $(var.A) = 2", false }, .{ "NOT ($(var.A) = 1 or $(var.A) = 2)", false }, }; for (cases) |c| { var buf: [256]u8 = undefined; const text = try std.fmt.bufPrint(&buf, "TF", .{c[0]}); const r = try run(text, &ab); defer r.deinit(); std.testing.expectEqualStrings(if (c[1]) "T" else "F", r.text.?) catch |e| { std.debug.print("condition: {s}\n", .{c[0]}); return e; }; } } test "comparing with an undefined variable is an error" { const r = try run("x", &ab); defer r.deinit(); try testing.expect(r.reported("1:1: error: the variable $(var.Missing) is not defined")); } test "an inequality needs integers" { const r = try run("x", &ab); defer r.deinit(); try testing.expect(r.reported("< compares integers, and \"good VAR\" is not one")); } test "a malformed condition" { const r = try run("x", &ab); defer r.deinit(); try testing.expect(r.reported("a condition cannot begin there")); } test "an if that is never closed" { const r = try run("a\nx", &ab); defer r.deinit(); try testing.expect(r.text == null); try testing.expect(r.reported("2:1: error: is never closed with ")); } test "endif, else and endforeach with nothing open" { for ([_][]const u8{ "", "", "", "" }) |t| { const r = try run(t, &ab); defer r.deinit(); try testing.expect(r.reported("has no abc", &ab); defer r.deinit(); try testing.expect(r.reported(" must be the last branch")); } test "directives in comments are not directives" { try expectOutput("ok", &.{}, "ok"); } // -- foreach ----------------------------------------------------------------- test "foreach over a list" { try expectOutput("[$(var.L)]", &.{}, "[1033][1041][1055]"); } test "foreach over a variable's list, nested, with nothing leaking out" { try expectOutput( "$(var.X)$(var.Y) leakedleaked", &.{}, "a1 a2 b1 b2 ", ); } test "a malformed foreach" { const r = try run("x", &.{}); defer r.deinit(); try testing.expect(r.reported(" is written ")); } test "a foreach that is never closed" { const r = try run("x", &.{}); defer r.deinit(); try testing.expect(r.reported(" is never closed with ")); } // -- includes ---------------------------------------------------------------- const Included = struct { arena: std.heap.ArenaAllocator, diags: zixl.Diagnostics, text: ?[]const u8, fn reported(r: *const Included, needle: []const u8) bool { var buf: [8192]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); r.diags.render(&w) catch return false; return std.mem.find(u8, w.buffered(), needle) != null; } }; fn runWithFiles(r: *Included, main: []const u8, files: []const zixl.FileSource.Memory.File, include_dirs: []const []const u8) !void { r.arena = .init(testing.allocator); r.diags = .init(r.arena.allocator()); const all = try r.arena.allocator().alloc(zixl.FileSource.Memory.File, files.len); @memcpy(all, files); const memory = try r.arena.allocator().create(zixl.FileSource.Memory); memory.* = .{ .files = all }; const out = zixl.preprocess(r.arena.allocator(), &r.diags, .{ .path = "src/main.wxs", .text = main }, .{ .files = memory.fileSource(), .include_dirs = include_dirs, }) catch null; r.text = if (out) |o| o.text else null; } const wxi_header = "\n\n"; test "an include beside the including file, and its variables" { var r: Included = undefined; try runWithFiles(&r, "", &.{ .{ .path = "src/part.wxi", .contents = wxi_header ++ "\n" }, }, &.{}); defer r.arena.deinit(); try testing.expectEqualStrings("", r.text.?); } test "an include found only through an include directory" { var r: Included = undefined; try runWithFiles(&r, "", &.{ .{ .path = "shared/common/part.wxi", .contents = "" }, }, &.{ "elsewhere", "shared" }); defer r.arena.deinit(); try testing.expectEqualStrings("", r.text.?); } test "an include found nowhere" { var r: Included = undefined; try runWithFiles(&r, "\n ", &.{}, &.{"shared"}); defer r.arena.deinit(); try testing.expect(r.text == null); try testing.expect(r.reported("src/main.wxs:2:3: error: the included file \"missing.wxi\" is not beside src/main.wxs nor in any include directory")); } test "an error two includes deep names the whole chain" { var r: Included = undefined; try runWithFiles(&r, "\n\n ", &.{ .{ .path = "src/middle.wxi", .contents = "\n \n" }, .{ .path = "src/inner.wxi", .contents = "\n\n\n \n" }, }, &.{}); defer r.arena.deinit(); try testing.expect(r.reported("src/inner.wxi:4:9: error: the variable $(var.Nope) is not defined (included from src/middle.wxi:2, included from src/main.wxs:3)")); } test "a file that includes itself" { var r: Included = undefined; try runWithFiles(&r, "", &.{ .{ .path = "src/loop.wxi", .contents = "" }, }, &.{}); defer r.arena.deinit(); try testing.expect(r.reported("src/loop.wxi includes itself")); } test "an included file must have Include at its root" { var r: Included = undefined; try runWithFiles(&r, "", &.{ .{ .path = "src/bad.wxi", .contents = "" }, }, &.{}); defer r.arena.deinit(); try testing.expect(r.reported("an included file's root element must be ")); } test "compile errors in an included file name the chain" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var diags: zixl.Diagnostics = .init(arena.allocator()); var files: zixl.FileSource.Memory = .{ .files = &.{ .{ .path = "p.wxs", .contents = \\ \\ \\ \\ \\ \\ \\ }, .{ .path = "features.wxi", .contents = "\n \n" }, } }; try testing.expectError(error.Invalid, zixl.buildDatabase(arena.allocator(), &diags, .{ .sources = &.{"p.wxs"}, .files = files.fileSource(), .timestamp = 0, })); var buf: [1024]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try diags.render(&w); testing.expect(std.mem.find(u8, w.buffered(), "features.wxi:2:19: error: attribute Bogus of is not supported (included from p.wxs:5)") != null) catch |e| { std.debug.print("{s}", .{w.buffered()}); return e; }; }