// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Preprocessing: turning WiX source text into plain XML. //! //! The preprocessor works on the text rather than on parsed XML. It carries //! out its directives and removes them: //! //! - ``, ``, `` and ``; //! - ``, `` and ``, with ``, `` //! and ``, keeping only the branch whose condition holds; //! - ``, which splices in the children of another file's //! `` element; //! - `` … ``, which repeats what it encloses once //! for each item of a semicolon-separated list. //! //! It replaces each variable reference, `$(var.NAME)`, `$(env.NAME)` or //! `$(sys.NAME)`, with its value, escaped for where it lands; everything //! else is copied exactly as it was written. Alongside the text it keeps a //! source map, so that a problem found in the output is still reported at //! the line and column of the file it came from, and the chain of includes //! that led there. const std = @import("std"); const Allocator = std.mem.Allocator; const Diagnostics = @import("Diagnostics.zig"); const Location = Diagnostics.Location; const FileSource = @import("FileSource.zig"); const Arch = @import("link.zig").Arch; pub const Error = error{ /// The WiX source has errors, which are in the diagnostics. Invalid, } || Allocator.Error; /// A WiX source and the path it was read from. pub const Source = struct { path: []const u8, text: []const u8, }; /// A variable defined before preprocessing starts, as `-D NAME=VALUE` does. pub const Define = struct { name: []const u8, value: []const u8, }; /// Where `$(env.NAME)` is looked up. The library never reads the process's /// environment itself. pub const Environment = struct { context: ?*const anyopaque = null, lookupFn: *const fn (context: ?*const anyopaque, name: []const u8) ?[]const u8 = lookupNothing, /// An environment in which nothing is defined. pub const empty: Environment = .{}; pub fn lookup(e: Environment, name: []const u8) ?[]const u8 { return e.lookupFn(e.context, name); } fn lookupNothing(_: ?*const anyopaque, _: []const u8) ?[]const u8 { return null; } /// The variables in `map`. pub fn fromMap(map: *const std.process.Environ.Map) Environment { return .{ .context = map, .lookupFn = lookupMap }; } fn lookupMap(context: ?*const anyopaque, name: []const u8) ?[]const u8 { const map: *const std.process.Environ.Map = @ptrCast(@alignCast(context.?)); return map.get(name); } }; pub const Options = struct { defines: []const Define = &.{}, environment: Environment = .empty, /// Where `` reads files from. Without one, nothing can be /// included. files: ?FileSource = null, /// Directories searched for an included file not found beside the file /// that includes it, in order. include_dirs: []const []const u8 = &.{}, /// The directory the build runs in, for `$(sys.CURRENTDIR)` and for /// making `$(sys.SOURCEFILEPATH)` absolute. current_dir: []const u8 = "", arch: Arch = .x64, }; /// Where each part of the preprocessed text came from. pub const SourceMap = struct { segments: std.ArrayList(Segment) = .empty, pub const Segment = struct { /// Where the segment begins in the preprocessed text. output: usize, path: []const u8, /// The text of the file it came from, for counting lines. text: []const u8, /// Where the segment begins in that file. input: usize, /// True when the segment was copied as it was written, so that each /// offset into it maps to the same offset in the file. A substituted /// value maps, all of it, to the reference it replaced. copied: bool, /// The `` that brought the file in, if it was included. included_from: ?*const Location, }; /// The location in a WiX source of an offset into the preprocessed text. pub fn locate(m: *const SourceMap, offset: usize) Location { const segs = m.segments.items; if (segs.len == 0) return .{ .path = "", .line = 1, .column = 1 }; var lo: usize = 0; var hi: usize = segs.len; while (hi - lo > 1) { const mid = (lo + hi) / 2; if (segs[mid].output <= offset) lo = mid else hi = mid; } const s = segs[lo]; const into = if (s.copied) offset -| s.output else 0; var loc: Location = .at(s.path, s.text, s.input + into); loc.included_from = s.included_from; return loc; } }; pub const Output = struct { text: []const u8, map: SourceMap, }; /// How deep includes may nest, which only a mistake would ever reach. const max_include_depth = 64; /// The most text preprocessing may produce. Includes and loops can each /// repeat what they hold, and together they can repeat it past any machine's /// memory; this stops that with an error instead. pub const max_output = 64 << 20; /// Preprocesses `source`, allocating the result with `arena` and adding what /// is wrong with it to `diags`. pub fn preprocess(arena: Allocator, diags: *Diagnostics, source: Source, options: Options) Error!Output { var p: Preprocessor = .{ .arena = arena, .diags = diags, .options = options, // A source in the WiX v4 namespace has WiX v4's system variables. .v4 = std.mem.find(u8, source.text, "http://wixtoolset.org/schemas/v4/wxs") != null, }; for (options.defines) |d| try p.variables.put(arena, d.name, d.value); const file: File = .{ .path = source.path, .text = source.text, .included_from = null, .depth = 0 }; p.process(&file, 0, source.text.len) catch |e| switch (e) { error.Stop => {}, error.OutOfMemory => return error.OutOfMemory, }; if (diags.hasErrors()) return error.Invalid; return .{ .text = p.out.items, .map = p.map }; } /// A file being preprocessed: the WiX source, or one it includes. const File = struct { path: []const u8, text: []const u8, included_from: ?*const Location, depth: usize, /// The file that included this one, to catch a file including itself. parent: ?*const File = null, fn location(f: *const File, offset: usize) Location { var loc: Location = .at(f.path, f.text, offset); loc.included_from = f.included_from; return loc; } }; /// A processing instruction: where it begins and ends, its name, and its /// arguments. const Instruction = struct { start: usize, end: usize, name: []const u8, args: []const u8, }; const Preprocessor = struct { arena: Allocator, diags: *Diagnostics, options: Options, variables: std.StringHashMapUnmanaged([]const u8) = .empty, out: std.ArrayList(u8) = .empty, map: SourceMap = .{}, v4: bool = false, /// Raised when nothing more can be read sensibly, such as at an /// instruction that is never closed. const Stop = error{Stop} || Allocator.Error; /// Where a reference to a variable sits, which decides how its value is /// escaped. const Context = enum { text, /// Inside a tag, outside any quotes. tag, attribute_single, attribute_double, }; /// Preprocesses `file.text[start..end]` into the output. fn process(p: *Preprocessor, file: *const File, start: usize, end: usize) Stop!void { const text = file.text[0..end]; var context: Context = .text; // The start of the run of text being copied as it is. var run_start = start; var i = start; while (i < end) { const rest = text[i..]; if (context == .text and std.mem.startsWith(u8, rest, ""); } else if (context == .text and std.mem.startsWith(u8, rest, ""); } else if (context == .text and std.mem.startsWith(u8, rest, " if (c == '<') .tag else .text, .tag => switch (c) { '>' => .text, '"' => .attribute_double, '\'' => .attribute_single, else => .tag, }, .attribute_double => if (c == '"') .tag else .attribute_double, .attribute_single => if (c == '\'') .tag else .attribute_single, }; i += 1; } } try p.copy(file, run_start, end); } /// The processing instruction beginning at `at`, which must start `") orelse { try p.diags.err(file.location(at), "a processing instruction is not closed", .{}); return error.Stop; }; const body = text[at + 2 .. close]; const name_end = std.mem.findAny(u8, body, " \t\r\n") orelse body.len; return .{ .start = at, .end = close + 2, .name = body[0..name_end], .args = std.mem.trim(u8, body[name_end..], " \t\r\n"), }; } /// Just past the first `terminator` after `start`, or the end of `text`. fn endOf(text: []const u8, start: usize, terminator: []const u8) usize { const at = std.mem.findPos(u8, text, start, terminator) orelse return text.len; return at + terminator.len; } const directives = [_][]const u8{ "define", "undef", "warning", "error", "if", "ifdef", "ifndef", "elseif", "else", "endif", "include", "foreach", "endforeach", }; fn isDirective(name: []const u8) bool { for (directives) |d| if (std.mem.eql(u8, name, d)) return true; return false; } fn grow(p: *Preprocessor, file: *const File, at: usize, n: usize) Stop!void { if (p.out.items.len + n <= max_output) return; try p.diags.err(file.location(at), "preprocessing produces more than {d} MiB, which only includes or loops repeating without end would", .{max_output >> 20}); return error.Stop; } /// Copies `file.text[from..to]` to the output as it is. fn copy(p: *Preprocessor, file: *const File, from: usize, to: usize) Stop!void { if (to <= from) return; try p.grow(file, from, to - from); try p.map.segments.append(p.arena, .{ .output = p.out.items.len, .path = file.path, .text = file.text, .input = from, .copied = true, .included_from = file.included_from, }); try p.out.appendSlice(p.arena, file.text[from..to]); } /// Writes `value` in place of the reference at `at`, escaped so that it /// stays the text it is wherever it lands. fn substitute(p: *Preprocessor, file: *const File, at: usize, value: []const u8, context: Context) Stop!void { try p.grow(file, at, value.len * 6); try p.map.segments.append(p.arena, .{ .output = p.out.items.len, .path = file.path, .text = file.text, .input = at, .copied = false, .included_from = file.included_from, }); for (value) |c| { const entity: ?[]const u8 = switch (c) { '&' => "&", '<' => "<", '>' => if (context == .text) ">" else null, '"' => if (context == .attribute_double) """ else null, '\'' => if (context == .attribute_single) "'" else null, else => null, }; if (entity) |e| try p.out.appendSlice(p.arena, e) else try p.out.append(p.arena, c); } } /// The value of the reference `$(inner)` at `at`, or "" after reporting /// why it has none. fn reference(p: *Preprocessor, file: *const File, at: usize, inner: []const u8) Allocator.Error![]const u8 { return (try p.lookup(file, at, inner, true)) orelse ""; } /// The value of `$(inner)`, or null when it has none; that is reported /// when `required`, and also when the reference itself is malformed. fn lookup(p: *Preprocessor, file: *const File, at: usize, inner: []const u8, required: bool) Allocator.Error!?[]const u8 { const here = file.location(at); const dot = std.mem.findScalar(u8, inner, '.') orelse { try p.diags.err(here, "$({s}) names no kind of variable; write $(var.{s}), $(env.{s}) or $(sys.{s})", .{ inner, inner, inner, inner }); return null; }; const kind = inner[0..dot]; const name = inner[dot + 1 ..]; if (std.mem.eql(u8, kind, "var")) { if (p.variables.get(name)) |v| return v; if (required) try p.diags.err(here, "the variable $(var.{s}) is not defined", .{name}); return null; } else if (std.mem.eql(u8, kind, "env")) { if (p.options.environment.lookup(name)) |v| return v; if (required) try p.diags.err(here, "the environment variable $(env.{s}) is not set", .{name}); return null; } else if (std.mem.eql(u8, kind, "sys")) { return try p.system(file, here, name); } try p.diags.err(here, "$({s}): \"{s}\" is not a kind of variable; the kinds are var, env and sys", .{ inner, kind }); return null; } fn system(p: *Preprocessor, file: *const File, here: Location, name: []const u8) Allocator.Error!?[]const u8 { // The directories end with a separator, as WiX documents. if (std.mem.eql(u8, name, "CURRENTDIR")) return try p.directory(p.options.current_dir); if (std.mem.eql(u8, name, "SOURCEFILEPATH")) return try p.absolute(file.path); if (std.mem.eql(u8, name, "SOURCEFILEDIR")) { const path = try p.absolute(file.path); return try p.directory(std.fs.path.dirname(path) orelse ""); } if (std.mem.eql(u8, name, "BUILDARCH")) return @tagName(p.options.arch); if (p.v4) { // WiX v4's, as its preprocessor documentation lists them. if (std.mem.eql(u8, name, "BUILDARCHSHORT")) return switch (p.options.arch) { .x86 => "X86", .x64 => "X64", .arm64 => "A64", }; if (std.mem.eql(u8, name, "WIXMAJORVERSION")) return "4"; try p.diags.err(here, "$(sys.{s}) is not a system variable; they are CURRENTDIR, SOURCEFILEDIR, SOURCEFILEPATH, BUILDARCH, BUILDARCHSHORT and WIXMAJORVERSION", .{name}); return null; } try p.diags.err(here, "$(sys.{s}) is not a system variable; they are CURRENTDIR, SOURCEFILEDIR, SOURCEFILEPATH and BUILDARCH", .{name}); return null; } fn directory(p: *Preprocessor, path: []const u8) Allocator.Error![]const u8 { if (path.len == 0 or std.fs.path.isSep(path[path.len - 1])) return path; return std.fmt.allocPrint(p.arena, "{s}{c}", .{ path, std.fs.path.sep }); } fn absolute(p: *Preprocessor, path: []const u8) Allocator.Error![]const u8 { if (std.fs.path.isAbsolute(path) or p.options.current_dir.len == 0) return path; return std.fs.path.join(p.arena, &.{ p.options.current_dir, path }); } /// Expands every reference in `text`, as a directive's arguments are /// expanded, without escaping: the value is text, not XML. fn expand(p: *Preprocessor, file: *const File, at: usize, text: []const u8) Allocator.Error![]const u8 { var out: std.ArrayList(u8) = .empty; var i: usize = 0; while (i < text.len) { if (std.mem.startsWith(u8, text[i..], "$$(")) { try out.appendSlice(p.arena, "$("); i += 3; } else if (std.mem.startsWith(u8, text[i..], "$(")) { const close = std.mem.findScalarPos(u8, text, i + 2, ')') orelse { try p.diags.err(file.location(at), "a variable reference is not closed with ')'", .{}); return out.items; }; try out.appendSlice(p.arena, try p.reference(file, at, text[i + 2 .. close])); i = close + 1; } else { try out.append(p.arena, text[i]); i += 1; } } return out.items; } /// Carries out the directive `pi`, returning where reading goes on. fn directive(p: *Preprocessor, file: *const File, text: []const u8, pi: Instruction) Stop!usize { const here = file.location(pi.start); const name = pi.name; if (std.mem.eql(u8, name, "define")) { try p.define(file, pi); } else if (std.mem.eql(u8, name, "undef")) { if (!p.variables.remove(pi.args)) { try p.diags.err(here, ": the variable is not defined", .{pi.args}); } } else if (std.mem.eql(u8, name, "warning")) { try p.diags.warn(here, "{s}", .{try p.expand(file, pi.start, pi.args)}); } else if (std.mem.eql(u8, name, "error")) { try p.diags.err(here, "{s}", .{try p.expand(file, pi.start, pi.args)}); } else if (std.mem.eql(u8, name, "if") or std.mem.eql(u8, name, "ifdef") or std.mem.eql(u8, name, "ifndef")) { return p.conditional(file, text, pi); } else if (std.mem.eql(u8, name, "foreach")) { return p.foreach(file, text, pi); } else if (std.mem.eql(u8, name, "include")) { try p.include(file, pi); } else { // elseif, else, endif or endforeach, with nothing open to close. try p.diags.err(here, " has no before it", .{ name, if (std.mem.eql(u8, name, "endforeach")) "foreach" else "if" }); } return pi.end; } fn define(p: *Preprocessor, file: *const File, pi: Instruction) Allocator.Error!void { const here = file.location(pi.start); // `` defines the variable with no value, which is // what `` asks about. const eq = std.mem.findScalar(u8, pi.args, '='); const variable = std.mem.trim(u8, if (eq) |e| pi.args[0..e] else pi.args, " \t\r\n"); if (!isName(variable)) { try p.diags.err(here, "\"{s}\" is not a variable name", .{variable}); return; } var value: []const u8 = ""; if (eq) |e| { value = std.mem.trim(u8, pi.args[e + 1 ..], " \t\r\n"); if (value.len >= 2 and value[0] == '"' and value[value.len - 1] == '"') value = value[1 .. value.len - 1]; value = try p.expand(file, pi.start, value); } try p.variables.put(p.arena, variable, value); } fn isName(s: []const u8) bool { if (s.len == 0) return false; for (s) |c| if (!(std.ascii.isAlphanumeric(c) or c == '_' or c == '.')) return false; return !std.ascii.isDigit(s[0]); } // -- conditionals and loops -------------------------------------------- /// The instructions that divide a conditional or a loop opened by /// `opening`, found by reading on and counting what else opens and /// closes, ending with the one that closes it. fn structure(p: *Preprocessor, file: *const File, text: []const u8, opening: Instruction, comptime loop: bool) Stop![]Instruction { var parts: std.ArrayList(Instruction) = .empty; var depth: usize = 0; var i = opening.end; while (i < text.len) { const rest = text[i..]; if (std.mem.startsWith(u8, rest, ""); } else if (std.mem.startsWith(u8, rest, ""); } else if (std.mem.startsWith(u8, rest, " 0) { depth -= 1; } else if (closes) { try parts.append(p.arena, pi); return parts.items; } else if (!loop and depth == 0 and (eql(pi.name, "elseif") or eql(pi.name, "else"))) { try parts.append(p.arena, pi); } } else { i += 1; } } try p.diags.err(file.location(opening.start), " is never closed with ", .{ opening.name, if (loop) "endforeach" else "endif" }); return error.Stop; } fn conditional(p: *Preprocessor, file: *const File, text: []const u8, opening: Instruction) Stop!usize { const parts = try p.structure(file, text, opening, false); for (parts[0 .. parts.len - 1], 0..) |part, k| { if (eql(part.name, "else") and k + 1 < parts.len - 1) { try p.diags.err(file.location(part.start), " must be the last branch before ", .{}); return parts[parts.len - 1].end; } } // Each branch is its condition and the text up to the next part. var condition = opening; var body_start = opening.end; for (parts) |part| { if (try p.holds(file, condition)) { try p.process(file, body_start, part.start); break; } condition = part; body_start = part.end; } return parts[parts.len - 1].end; } /// Whether the branch opened by `pi` is the one to keep. fn holds(p: *Preprocessor, file: *const File, pi: Instruction) Stop!bool { if (eql(pi.name, "else")) return true; if (eql(pi.name, "ifdef")) return p.variables.contains(pi.args); if (eql(pi.name, "ifndef")) return !p.variables.contains(pi.args); var e: Expression = .{ .p = p, .file = file, .at = pi.start, .text = pi.args }; return e.evaluate() catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.Invalid => false, }; } fn foreach(p: *Preprocessor, file: *const File, text: []const u8, opening: Instruction) Stop!usize { const parts = try p.structure(file, text, opening, true); const close = parts[parts.len - 1]; const here = file.location(opening.start); // var words = std.mem.tokenizeAny(u8, opening.args, " \t\r\n"); const variable = words.next() orelse ""; const in = words.next() orelse ""; if (!isName(variable) or !eql(in, "in")) { try p.diags.err(here, " is written ", .{}); return close.end; } const list = try p.expand(file, opening.start, std.mem.trim(u8, words.rest(), " \t\r\n")); var items = std.mem.splitScalar(u8, list, ';'); while (items.next()) |raw| { const item = std.mem.trim(u8, raw, " \t\r\n"); if (item.len == 0) continue; // Each pass works on a copy of the variables, so that nothing // defined inside the loop outlives it. const saved = try p.variables.clone(p.arena); try p.variables.put(p.arena, variable, item); try p.process(file, opening.end, close.start); p.variables = saved; } return close.end; } // -- includes --------------------------------------------------------- fn include(p: *Preprocessor, file: *const File, pi: Instruction) Stop!void { const here = file.location(pi.start); const files = p.options.files orelse { try p.diags.err(here, " needs files to read, and none were given", .{}); return; }; var wanted = try p.expand(file, pi.start, pi.args); if (wanted.len >= 2 and wanted[0] == '"' and wanted[wanted.len - 1] == '"') wanted = wanted[1 .. wanted.len - 1]; const normalized = try p.arena.dupe(u8, wanted); std.mem.replaceScalar(u8, normalized, '\\', '/'); if (normalized.len == 0) { try p.diags.err(here, " names no file", .{}); return; } if (file.depth >= max_include_depth) { try p.diags.err(here, "includes nest more than {d} deep", .{max_include_depth}); return error.Stop; } // Beside the including file first, then in each include directory. var candidates: std.ArrayList([]const u8) = .empty; const dir = std.fs.path.dirnamePosix(slashes(p.arena, file.path) catch file.path); try candidates.append(p.arena, if (dir) |d| try std.fmt.allocPrint(p.arena, "{s}/{s}", .{ d, normalized }) else normalized); for (p.options.include_dirs) |d| { try candidates.append(p.arena, try std.fmt.allocPrint(p.arena, "{s}/{s}", .{ std.mem.trimEnd(u8, try slashes(p.arena, d), "/"), normalized })); } var found: ?struct { []const u8, []const u8 } = null; for (candidates.items) |path| { const contents = files.read(p.arena, path) catch |e| switch (e) { error.FileNotFound => continue, error.OutOfMemory => return error.OutOfMemory, error.ReadFailed => { try p.diags.err(here, "the included file {s} cannot be read", .{path}); return; }, }; found = .{ path, contents }; break; } const path, const contents = found orelse { try p.diags.err(here, "the included file \"{s}\" is not beside {s} nor in any include directory", .{ wanted, file.path }); return; }; var ancestor: ?*const File = file; while (ancestor) |a| : (ancestor = a.parent) { if (eql(a.path, path)) { try p.diags.err(here, "{s} includes itself", .{path}); return; } } const from = try p.arena.create(Location); from.* = here; const child: File = .{ .path = path, .text = contents, .included_from = from, .depth = file.depth + 1, .parent = file, }; const start, const end = (try p.includeBody(&child)) orelse return; try p.process(&child, start, end); } /// Where the children of an included file's `` element begin /// and end, or null after reporting that it has none. fn includeBody(p: *Preprocessor, file: *const File) Stop!?struct { usize, usize } { const text = file.text; var i: usize = 0; // Past the XML declaration, comments and whitespace to the root. while (i < text.len) { if (std.mem.startsWith(u8, text[i..], ""); } else if (std.mem.startsWith(u8, text[i..], ""); } else if (std.ascii.isWhitespace(text[i])) { i += 1; } else break; } const is_include = std.mem.startsWith(u8, text[i..], "' or text[i + 8] == '/' or std.ascii.isWhitespace(text[i + 8])); if (!is_include) { try p.diags.err(file.location(i), "an included file's root element must be ", .{}); return null; } const open_end = std.mem.findScalarPos(u8, text, i, '>') orelse { try p.diags.err(file.location(i), "the element is not closed", .{}); return null; }; if (text[open_end - 1] == '/') return .{ open_end + 1, open_end + 1 }; const close = std.mem.findLast(u8, text, " element is not closed", .{}); return null; }; return .{ open_end + 1, close }; } fn slashes(arena: Allocator, path: []const u8) Allocator.Error![]const u8 { const out = try arena.dupe(u8, path); std.mem.replaceScalar(u8, out, '\\', '/'); return out; } }; fn eql(a: []const u8, b: []const u8) bool { return std.mem.eql(u8, a, b); } /// The condition of an `` or ``, as WiX documents it: /// read left to right, with `and` and `or` alike in precedence, below /// `not`, below the comparisons. `=`, `!=` and `~=` (which ignores case) /// compare strings; `<`, `<=`, `>` and `>=` compare integers. A literal may /// be quoted or not; a variable on its own asks whether it is defined. const Expression = struct { p: *Preprocessor, file: *const File, /// Where the directive is, for diagnostics. at: usize, text: []const u8, pos: usize = 0, const EvalError = error{Invalid} || Allocator.Error; const Token = union(enum) { open, close, op: []const u8, @"and", @"or", not, literal: []const u8, /// The text between `$(` and `)`. variable: []const u8, end, }; fn evaluate(e: *Expression) EvalError!bool { const v = try e.disjunction(); if (try e.peek() != .end) return e.fail("unexpected text after the condition", .{}); return v; } fn fail(e: *Expression, comptime fmt: []const u8, args: anytype) EvalError { e.p.diags.err(e.file.location(e.at), ": " ++ fmt, .{e.text} ++ args) catch return error.OutOfMemory; return error.Invalid; } /// `and` and `or`, left to right. fn disjunction(e: *Expression) EvalError!bool { var v = try e.negation(); while (true) { const t = try e.peek(); switch (t) { .@"and" => { _ = try e.next(); const rhs = try e.negation(); v = v and rhs; }, .@"or" => { _ = try e.next(); const rhs = try e.negation(); v = v or rhs; }, else => return v, } } } fn negation(e: *Expression) EvalError!bool { if (try e.peek() == .not) { _ = try e.next(); return !(try e.negation()); } return e.primary(); } fn primary(e: *Expression) EvalError!bool { const t = try e.next(); switch (t) { .open => { const v = try e.disjunction(); if (try e.next() != .close) return e.fail("a '(' is not closed", .{}); return v; }, .literal, .variable => { const following = try e.peek(); if (following != .op) { if (t == .variable) return (try e.p.lookup(e.file, e.at, t.variable, false)) != null; return e.fail("\"{s}\" is not a condition", .{t.literal}); } const op = (try e.next()).op; const rhs = try e.next(); if (rhs != .literal and rhs != .variable) return e.fail("{s} needs something to compare with", .{op}); return e.compare(try e.value(t), op, try e.value(rhs)); }, .end => return e.fail("a condition is missing", .{}), else => return e.fail("a condition cannot begin there", .{}), } } fn value(e: *Expression, t: Token) EvalError![]const u8 { return switch (t) { .literal => |l| l, .variable => |v| (try e.p.lookup(e.file, e.at, v, true)) orelse return error.Invalid, else => unreachable, }; } fn compare(e: *Expression, a: []const u8, op: []const u8, b: []const u8) EvalError!bool { if (eql(op, "=")) return eql(a, b); if (eql(op, "!=")) return !eql(a, b); if (eql(op, "~=")) return std.ascii.eqlIgnoreCase(a, b); const x = std.fmt.parseInt(i64, std.mem.trim(u8, a, " "), 10) catch return e.fail("{s} compares integers, and \"{s}\" is not one", .{ op, a }); const y = std.fmt.parseInt(i64, std.mem.trim(u8, b, " "), 10) catch return e.fail("{s} compares integers, and \"{s}\" is not one", .{ op, b }); if (eql(op, "<")) return x < y; if (eql(op, "<=")) return x <= y; if (eql(op, ">")) return x > y; return x >= y; } fn peek(e: *Expression) EvalError!Token { const saved = e.pos; defer e.pos = saved; return e.next(); } fn next(e: *Expression) EvalError!Token { const s = e.text; while (e.pos < s.len and std.ascii.isWhitespace(s[e.pos])) e.pos += 1; if (e.pos == s.len) return .end; const c = s[e.pos]; switch (c) { '(' => { e.pos += 1; return .open; }, ')' => { e.pos += 1; return .close; }, '"' => { const close = std.mem.findScalarPos(u8, s, e.pos + 1, '"') orelse return e.fail("a quoted literal is not closed", .{}); defer e.pos = close + 1; return .{ .literal = s[e.pos + 1 .. close] }; }, '=', '!', '~', '<', '>' => { for ([_][]const u8{ "!=", "~=", "<=", ">=", "=", "<", ">" }) |op| { if (std.mem.startsWith(u8, s[e.pos..], op)) { e.pos += op.len; return .{ .op = op }; } } return e.fail("'{c}' is not an operator", .{c}); }, '$' => if (std.mem.startsWith(u8, s[e.pos..], "$(")) { const close = std.mem.findScalarPos(u8, s, e.pos + 2, ')') orelse return e.fail("a variable reference is not closed", .{}); defer e.pos = close + 1; return .{ .variable = s[e.pos + 2 .. close] }; }, else => {}, } const start = e.pos; while (e.pos < s.len) : (e.pos += 1) { const d = s[e.pos]; if (std.ascii.isWhitespace(d) or std.mem.findScalar(u8, "()\"=!~<>", d) != null) break; } const word = s[start..e.pos]; if (std.ascii.eqlIgnoreCase(word, "and")) return .@"and"; if (std.ascii.eqlIgnoreCase(word, "or")) return .@"or"; if (std.ascii.eqlIgnoreCase(word, "not")) return .not; return .{ .literal = word }; } };