master
   1const std = @import("std");
   2const code_pages = @import("code_pages.zig");
   3const SupportedCodePage = code_pages.SupportedCodePage;
   4const lang = @import("lang.zig");
   5const res = @import("res.zig");
   6const Allocator = std.mem.Allocator;
   7const lex = @import("lex.zig");
   8const cvtres = @import("cvtres.zig");
   9
  10/// This is what /SL 100 will set the maximum string literal length to
  11pub const max_string_literal_length_100_percent = 8192;
  12
  13pub const usage_string_after_command_name =
  14    \\ [options] [--] <INPUT> [<OUTPUT>]
  15    \\
  16    \\The sequence -- can be used to signify when to stop parsing options.
  17    \\This is necessary when the input path begins with a forward slash.
  18    \\
  19    \\Supported option prefixes are /, -, and --, so e.g. /h, -h, and --h all work.
  20    \\Drop-in compatible with the Microsoft Resource Compiler.
  21    \\
  22    \\Supported Win32 RC Options:
  23    \\  /?, /h                  Print this help and exit.
  24    \\  /v                      Verbose (print progress messages).
  25    \\  /d <name>[=<value>]     Define a symbol (during preprocessing).
  26    \\  /u <name>               Undefine a symbol (during preprocessing).
  27    \\  /fo <value>             Specify output file path.
  28    \\  /l <value>              Set default language using hexadecimal id (ex: 409).
  29    \\  /ln <value>             Set default language using language name (ex: en-us).
  30    \\  /i <value>              Add an include path.
  31    \\  /x                      Ignore INCLUDE environment variable.
  32    \\  /c <value>              Set default code page (ex: 65001).
  33    \\  /w                      Warn on invalid code page in .rc (instead of error).
  34    \\  /y                      Suppress warnings for duplicate control IDs.
  35    \\  /n                      Null-terminate all strings in string tables.
  36    \\  /sl <value>             Specify string literal length limit in percentage (1-100)
  37    \\                          where 100 corresponds to a limit of 8192. If the /sl
  38    \\                          option is not specified, the default limit is 4097.
  39    \\  /p                      Only run the preprocessor and output a .rcpp file.
  40    \\
  41    \\No-op Win32 RC Options:
  42    \\  /nologo, /a, /r         Options that are recognized but do nothing.
  43    \\
  44    \\Unsupported Win32 RC Options:
  45    \\  /fm, /q, /g, /gn, /g1, /g2     Unsupported MUI-related options.
  46    \\  /?c, /hc, /t, /tp:<prefix>,    Unsupported LCX/LCE-related options.
  47    \\     /tn, /tm, /tc, /tw, /te,
  48    \\                    /ti, /ta
  49    \\  /z                             Unsupported font-substitution-related option.
  50    \\  /s                             Unsupported HWB-related option.
  51    \\
  52    \\Custom Options (resinator-specific):
  53    \\  /:no-preprocess           Do not run the preprocessor.
  54    \\  /:debug                   Output the preprocessed .rc file and the parsed AST.
  55    \\  /:auto-includes <value>   Set the automatic include path detection behavior.
  56    \\    any                     (default) Use MSVC if available, fall back to MinGW
  57    \\    msvc                    Use MSVC include paths (must be present on the system)
  58    \\    gnu                     Use MinGW include paths
  59    \\    none                    Do not use any autodetected include paths
  60    \\  /:depfile <path>          Output a file containing a list of all the files that
  61    \\                            the .rc includes or otherwise depends on.
  62    \\  /:depfile-fmt <value>     Output format of the depfile, if /:depfile is set.
  63    \\    json                    (default) A top-level JSON array of paths
  64    \\  /:input-format <value>    If not specified, the input format is inferred.
  65    \\    rc                      (default if input format cannot be inferred)
  66    \\    res                     Compiled .rc file, implies /:output-format coff
  67    \\    rcpp                    Preprocessed .rc file, implies /:no-preprocess
  68    \\  /:output-format <value>   If not specified, the output format is inferred.
  69    \\    res                     (default if output format cannot be inferred)
  70    \\    coff                    COFF object file (extension: .obj or .o)
  71    \\    rcpp                    Preprocessed .rc file, implies /p
  72    \\  /:target <arch>           Set the target machine for COFF object files.
  73    \\                            Can be specified either as PE/COFF machine constant
  74    \\                            name (X64, ARM64, etc) or Zig/LLVM CPU name (x86_64,
  75    \\                            aarch64, etc). The default is X64 (aka x86_64).
  76    \\                            Also accepts a full Zig/LLVM triple, but everything
  77    \\                            except the architecture is ignored.
  78    \\
  79    \\Note: For compatibility reasons, all custom options start with :
  80    \\
  81;
  82
  83pub fn writeUsage(writer: *std.Io.Writer, command_name: []const u8) !void {
  84    try writer.writeAll("Usage: ");
  85    try writer.writeAll(command_name);
  86    try writer.writeAll(usage_string_after_command_name);
  87}
  88
  89pub const Diagnostics = struct {
  90    errors: std.ArrayList(ErrorDetails) = .empty,
  91    allocator: Allocator,
  92
  93    pub const ErrorDetails = struct {
  94        arg_index: usize,
  95        arg_span: ArgSpan = .{},
  96        msg: std.ArrayList(u8) = .empty,
  97        type: Type = .err,
  98        print_args: bool = true,
  99
 100        pub const Type = enum { err, warning, note };
 101        pub const ArgSpan = struct {
 102            point_at_next_arg: bool = false,
 103            name_offset: usize = 0,
 104            prefix_len: usize = 0,
 105            value_offset: usize = 0,
 106            name_len: usize = 0,
 107        };
 108    };
 109
 110    pub fn init(allocator: Allocator) Diagnostics {
 111        return .{
 112            .allocator = allocator,
 113        };
 114    }
 115
 116    pub fn deinit(self: *Diagnostics) void {
 117        for (self.errors.items) |*details| {
 118            details.msg.deinit(self.allocator);
 119        }
 120        self.errors.deinit(self.allocator);
 121    }
 122
 123    pub fn append(self: *Diagnostics, error_details: ErrorDetails) !void {
 124        try self.errors.append(self.allocator, error_details);
 125    }
 126
 127    pub fn renderToStdErr(self: *Diagnostics, args: []const []const u8) void {
 128        const stderr, const ttyconf = std.debug.lockStderrWriter(&.{});
 129        defer std.debug.unlockStderrWriter();
 130        self.renderToWriter(args, stderr, ttyconf) catch return;
 131    }
 132
 133    pub fn renderToWriter(self: *Diagnostics, args: []const []const u8, writer: *std.Io.Writer, config: std.Io.tty.Config) !void {
 134        for (self.errors.items) |err_details| {
 135            try renderErrorMessage(writer, config, err_details, args);
 136        }
 137    }
 138
 139    pub fn hasError(self: *const Diagnostics) bool {
 140        for (self.errors.items) |err| {
 141            if (err.type == .err) return true;
 142        }
 143        return false;
 144    }
 145};
 146
 147pub const Options = struct {
 148    allocator: Allocator,
 149    input_source: IoSource = .{ .filename = &[_]u8{} },
 150    output_source: IoSource = .{ .filename = &[_]u8{} },
 151    extra_include_paths: std.ArrayList([]const u8) = .empty,
 152    ignore_include_env_var: bool = false,
 153    preprocess: Preprocess = .yes,
 154    default_language_id: ?u16 = null,
 155    default_code_page: ?SupportedCodePage = null,
 156    verbose: bool = false,
 157    symbols: std.StringArrayHashMapUnmanaged(SymbolValue) = .empty,
 158    null_terminate_string_table_strings: bool = false,
 159    max_string_literal_codepoints: u15 = lex.default_max_string_literal_codepoints,
 160    silent_duplicate_control_ids: bool = false,
 161    warn_instead_of_error_on_invalid_code_page: bool = false,
 162    debug: bool = false,
 163    print_help_and_exit: bool = false,
 164    auto_includes: AutoIncludes = .any,
 165    depfile_path: ?[]const u8 = null,
 166    depfile_fmt: DepfileFormat = .json,
 167    input_format: InputFormat = .rc,
 168    output_format: OutputFormat = .res,
 169    coff_options: cvtres.CoffOptions = .{},
 170
 171    pub const IoSource = union(enum) {
 172        stdio: std.fs.File,
 173        filename: []const u8,
 174    };
 175    pub const AutoIncludes = enum { any, msvc, gnu, none };
 176    pub const DepfileFormat = enum { json };
 177    pub const InputFormat = enum { rc, res, rcpp };
 178    pub const OutputFormat = enum {
 179        res,
 180        coff,
 181        rcpp,
 182
 183        pub fn extension(format: OutputFormat) []const u8 {
 184            return switch (format) {
 185                .rcpp => ".rcpp",
 186                .coff => ".obj",
 187                .res => ".res",
 188            };
 189        }
 190    };
 191    pub const Preprocess = enum { no, yes, only };
 192    pub const SymbolAction = enum { define, undefine };
 193    pub const SymbolValue = union(SymbolAction) {
 194        define: []const u8,
 195        undefine: void,
 196
 197        pub fn deinit(self: SymbolValue, allocator: Allocator) void {
 198            switch (self) {
 199                .define => |value| allocator.free(value),
 200                .undefine => {},
 201            }
 202        }
 203    };
 204
 205    /// Does not check that identifier contains only valid characters
 206    pub fn define(self: *Options, identifier: []const u8, value: []const u8) !void {
 207        if (self.symbols.getPtr(identifier)) |val_ptr| {
 208            // If the symbol is undefined, then that always takes precedence so
 209            // we shouldn't change anything.
 210            if (val_ptr.* == .undefine) return;
 211            // Otherwise, the new value takes precedence.
 212            const duped_value = try self.allocator.dupe(u8, value);
 213            errdefer self.allocator.free(duped_value);
 214            val_ptr.deinit(self.allocator);
 215            val_ptr.* = .{ .define = duped_value };
 216            return;
 217        }
 218        const duped_key = try self.allocator.dupe(u8, identifier);
 219        errdefer self.allocator.free(duped_key);
 220        const duped_value = try self.allocator.dupe(u8, value);
 221        errdefer self.allocator.free(duped_value);
 222        try self.symbols.put(self.allocator, duped_key, .{ .define = duped_value });
 223    }
 224
 225    /// Does not check that identifier contains only valid characters
 226    pub fn undefine(self: *Options, identifier: []const u8) !void {
 227        if (self.symbols.getPtr(identifier)) |action| {
 228            action.deinit(self.allocator);
 229            action.* = .{ .undefine = {} };
 230            return;
 231        }
 232        const duped_key = try self.allocator.dupe(u8, identifier);
 233        errdefer self.allocator.free(duped_key);
 234        try self.symbols.put(self.allocator, duped_key, .{ .undefine = {} });
 235    }
 236
 237    /// If the current input filename:
 238    /// - does not have an extension, and
 239    /// - does not exist in the cwd, and
 240    /// - the input format is .rc
 241    /// then this function will append `.rc` to the input filename
 242    ///
 243    /// Note: This behavior is different from the Win32 compiler.
 244    ///       It always appends .RC if the filename does not have
 245    ///       a `.` in it and it does not even try the verbatim name
 246    ///       in that scenario.
 247    ///
 248    /// The approach taken here is meant to give us a 'best of both
 249    /// worlds' situation where we'll be compatible with most use-cases
 250    /// of the .rc extension being omitted from the CLI args, but still
 251    /// work fine if the file itself does not have an extension.
 252    pub fn maybeAppendRC(options: *Options, cwd: std.fs.Dir) !void {
 253        switch (options.input_source) {
 254            .stdio => return,
 255            .filename => {},
 256        }
 257        if (options.input_format == .rc and std.fs.path.extension(options.input_source.filename).len == 0) {
 258            cwd.access(options.input_source.filename, .{}) catch |err| switch (err) {
 259                error.FileNotFound => {
 260                    var filename_bytes = try options.allocator.alloc(u8, options.input_source.filename.len + 3);
 261                    @memcpy(filename_bytes[0..options.input_source.filename.len], options.input_source.filename);
 262                    @memcpy(filename_bytes[filename_bytes.len - 3 ..], ".rc");
 263                    options.allocator.free(options.input_source.filename);
 264                    options.input_source = .{ .filename = filename_bytes };
 265                },
 266                else => {},
 267            };
 268        }
 269    }
 270
 271    pub fn deinit(self: *Options) void {
 272        for (self.extra_include_paths.items) |extra_include_path| {
 273            self.allocator.free(extra_include_path);
 274        }
 275        self.extra_include_paths.deinit(self.allocator);
 276        switch (self.input_source) {
 277            .stdio => {},
 278            .filename => |filename| self.allocator.free(filename),
 279        }
 280        switch (self.output_source) {
 281            .stdio => {},
 282            .filename => |filename| self.allocator.free(filename),
 283        }
 284        var symbol_it = self.symbols.iterator();
 285        while (symbol_it.next()) |entry| {
 286            self.allocator.free(entry.key_ptr.*);
 287            entry.value_ptr.deinit(self.allocator);
 288        }
 289        self.symbols.deinit(self.allocator);
 290        if (self.depfile_path) |depfile_path| {
 291            self.allocator.free(depfile_path);
 292        }
 293        if (self.coff_options.define_external_symbol) |symbol_name| {
 294            self.allocator.free(symbol_name);
 295        }
 296    }
 297
 298    pub fn dumpVerbose(self: *const Options, writer: *std.Io.Writer) !void {
 299        const input_source_name = switch (self.input_source) {
 300            .stdio => "<stdin>",
 301            .filename => |filename| filename,
 302        };
 303        const output_source_name = switch (self.output_source) {
 304            .stdio => "<stdout>",
 305            .filename => |filename| filename,
 306        };
 307        try writer.print("Input filename: {s} (format={s})\n", .{ input_source_name, @tagName(self.input_format) });
 308        try writer.print("Output filename: {s} (format={s})\n", .{ output_source_name, @tagName(self.output_format) });
 309        if (self.output_format == .coff) {
 310            try writer.print(" Target machine type for COFF: {s}\n", .{@tagName(self.coff_options.target)});
 311        }
 312
 313        if (self.extra_include_paths.items.len > 0) {
 314            try writer.writeAll(" Extra include paths:\n");
 315            for (self.extra_include_paths.items) |extra_include_path| {
 316                try writer.print("  \"{s}\"\n", .{extra_include_path});
 317            }
 318        }
 319        if (self.ignore_include_env_var) {
 320            try writer.writeAll(" The INCLUDE environment variable will be ignored\n");
 321        }
 322        if (self.preprocess == .no) {
 323            try writer.writeAll(" The preprocessor will not be invoked\n");
 324        } else if (self.preprocess == .only) {
 325            try writer.writeAll(" Only the preprocessor will be invoked\n");
 326        }
 327        if (self.symbols.count() > 0) {
 328            try writer.writeAll(" Symbols:\n");
 329            var it = self.symbols.iterator();
 330            while (it.next()) |symbol| {
 331                try writer.print("  {s} {s}", .{ switch (symbol.value_ptr.*) {
 332                    .define => "#define",
 333                    .undefine => "#undef",
 334                }, symbol.key_ptr.* });
 335                if (symbol.value_ptr.* == .define) {
 336                    try writer.print(" {s}", .{symbol.value_ptr.define});
 337                }
 338                try writer.writeAll("\n");
 339            }
 340        }
 341        if (self.null_terminate_string_table_strings) {
 342            try writer.writeAll(" Strings in string tables will be null-terminated\n");
 343        }
 344        if (self.max_string_literal_codepoints != lex.default_max_string_literal_codepoints) {
 345            try writer.print(" Max string literal length: {}\n", .{self.max_string_literal_codepoints});
 346        }
 347        if (self.silent_duplicate_control_ids) {
 348            try writer.writeAll(" Duplicate control IDs will not emit warnings\n");
 349        }
 350        if (self.silent_duplicate_control_ids) {
 351            try writer.writeAll(" Invalid code page in .rc will produce a warning (instead of an error)\n");
 352        }
 353
 354        const language_id = self.default_language_id orelse res.Language.default;
 355        const language_name = language_name: {
 356            if (std.enums.fromInt(lang.LanguageId, language_id)) |lang_enum_val| {
 357                break :language_name @tagName(lang_enum_val);
 358            }
 359            if (language_id == lang.LOCALE_CUSTOM_UNSPECIFIED) {
 360                break :language_name "LOCALE_CUSTOM_UNSPECIFIED";
 361            }
 362            break :language_name "<UNKNOWN>";
 363        };
 364        try writer.print("Default language: {s} (id=0x{x})\n", .{ language_name, language_id });
 365
 366        const code_page = self.default_code_page orelse .windows1252;
 367        try writer.print("Default codepage: {s} (id={})\n", .{ @tagName(code_page), @intFromEnum(code_page) });
 368    }
 369};
 370
 371pub const Arg = struct {
 372    prefix: enum { long, short, slash },
 373    name_offset: usize,
 374    full: []const u8,
 375
 376    pub fn fromString(str: []const u8) ?@This() {
 377        if (std.mem.startsWith(u8, str, "--")) {
 378            return .{ .prefix = .long, .name_offset = 2, .full = str };
 379        } else if (std.mem.startsWith(u8, str, "-")) {
 380            return .{ .prefix = .short, .name_offset = 1, .full = str };
 381        } else if (std.mem.startsWith(u8, str, "/")) {
 382            return .{ .prefix = .slash, .name_offset = 1, .full = str };
 383        }
 384        return null;
 385    }
 386
 387    pub fn prefixSlice(self: Arg) []const u8 {
 388        return self.full[0..(if (self.prefix == .long) 2 else 1)];
 389    }
 390
 391    pub fn name(self: Arg) []const u8 {
 392        return self.full[self.name_offset..];
 393    }
 394
 395    pub fn optionWithoutPrefix(self: Arg, option_len: usize) []const u8 {
 396        if (option_len == 0) return self.name();
 397        return self.name()[0..option_len];
 398    }
 399
 400    pub fn missingSpan(self: Arg) Diagnostics.ErrorDetails.ArgSpan {
 401        return .{
 402            .point_at_next_arg = true,
 403            .value_offset = 0,
 404            .name_offset = self.name_offset,
 405            .prefix_len = self.prefixSlice().len,
 406        };
 407    }
 408
 409    pub fn optionAndAfterSpan(self: Arg) Diagnostics.ErrorDetails.ArgSpan {
 410        return self.optionSpan(0);
 411    }
 412
 413    pub fn optionSpan(self: Arg, option_len: usize) Diagnostics.ErrorDetails.ArgSpan {
 414        return .{
 415            .name_offset = self.name_offset,
 416            .prefix_len = self.prefixSlice().len,
 417            .name_len = option_len,
 418        };
 419    }
 420
 421    pub fn looksLikeFilepath(self: Arg) bool {
 422        const meets_min_requirements = self.prefix == .slash and isSupportedInputExtension(std.fs.path.extension(self.full));
 423        if (!meets_min_requirements) return false;
 424
 425        const could_be_fo_option = could_be_fo_option: {
 426            var window_it = std.mem.window(u8, self.full[1..], 2, 1);
 427            while (window_it.next()) |window| {
 428                if (std.ascii.eqlIgnoreCase(window, "fo")) break :could_be_fo_option true;
 429                // If we see '/' before "fo", then it's not possible for this to be a valid
 430                // `/fo` option.
 431                if (window[0] == '/') break;
 432            }
 433            break :could_be_fo_option false;
 434        };
 435        if (!could_be_fo_option) return true;
 436
 437        // It's still possible for a file path to look like a /fo option but not actually
 438        // be one, e.g. `/foo/bar.rc`. As a last ditch effort to reduce false negatives,
 439        // check if the file path exists and, if so, then we ignore the 'could be /fo option'-ness
 440        std.fs.accessAbsolute(self.full, .{}) catch return false;
 441        return true;
 442    }
 443
 444    pub const Value = struct {
 445        slice: []const u8,
 446        /// Amount to increment the arg index to skip over both the option and the value arg(s)
 447        /// e.g. 1 if /<option><value>, 2 if /<option> <value>
 448        index_increment: u2 = 1,
 449
 450        pub fn argSpan(self: Value, arg: Arg) Diagnostics.ErrorDetails.ArgSpan {
 451            const prefix_len = arg.prefixSlice().len;
 452            switch (self.index_increment) {
 453                1 => return .{
 454                    .value_offset = @intFromPtr(self.slice.ptr) - @intFromPtr(arg.full.ptr),
 455                    .prefix_len = prefix_len,
 456                    .name_offset = arg.name_offset,
 457                },
 458                2 => return .{
 459                    .point_at_next_arg = true,
 460                    .prefix_len = prefix_len,
 461                    .name_offset = arg.name_offset,
 462                },
 463                else => unreachable,
 464            }
 465        }
 466
 467        pub fn index(self: Value, arg_index: usize) usize {
 468            if (self.index_increment == 2) return arg_index + 1;
 469            return arg_index;
 470        }
 471    };
 472
 473    pub fn value(self: Arg, option_len: usize, index: usize, args: []const []const u8) error{MissingValue}!Value {
 474        const rest = self.full[self.name_offset + option_len ..];
 475        if (rest.len > 0) return .{ .slice = rest };
 476        if (index + 1 >= args.len) return error.MissingValue;
 477        return .{ .slice = args[index + 1], .index_increment = 2 };
 478    }
 479
 480    pub const Context = struct {
 481        index: usize,
 482        option_len: usize,
 483        arg: Arg,
 484        value: Value,
 485    };
 486};
 487
 488pub const ParseError = error{ParseError} || Allocator.Error;
 489
 490/// Note: Does not run `Options.maybeAppendRC` automatically. If that behavior is desired,
 491///       it must be called separately.
 492pub fn parse(allocator: Allocator, args: []const []const u8, diagnostics: *Diagnostics) ParseError!Options {
 493    var options = Options{ .allocator = allocator };
 494    errdefer options.deinit();
 495
 496    var output_filename: ?[]const u8 = null;
 497    var output_filename_context: union(enum) {
 498        unspecified: void,
 499        positional: usize,
 500        arg: Arg.Context,
 501    } = .{ .unspecified = {} };
 502    var output_format: ?Options.OutputFormat = null;
 503    var output_format_context: Arg.Context = undefined;
 504    var input_format: ?Options.InputFormat = null;
 505    var input_format_context: Arg.Context = undefined;
 506    var input_filename_arg_i: usize = undefined;
 507    var preprocess_only_context: Arg.Context = undefined;
 508    var depfile_context: Arg.Context = undefined;
 509
 510    var arg_i: usize = 0;
 511    next_arg: while (arg_i < args.len) {
 512        var arg = Arg.fromString(args[arg_i]) orelse break;
 513        if (arg.name().len == 0) {
 514            switch (arg.prefix) {
 515                // -- on its own ends arg parsing
 516                .long => {
 517                    arg_i += 1;
 518                    break;
 519                },
 520                // - or / on its own is an error
 521                else => {
 522                    var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = arg.optionAndAfterSpan() };
 523                    try err_details.msg.print(allocator, "invalid option: {s}", .{arg.prefixSlice()});
 524                    try diagnostics.append(err_details);
 525                    arg_i += 1;
 526                    continue :next_arg;
 527                },
 528            }
 529        }
 530
 531        const args_remaining = args.len - arg_i;
 532        if (args_remaining <= 2 and arg.looksLikeFilepath()) {
 533            var err_details = Diagnostics.ErrorDetails{ .type = .note, .print_args = true, .arg_index = arg_i };
 534            try err_details.msg.appendSlice(allocator, "this argument was inferred to be a filepath, so argument parsing was terminated");
 535            try diagnostics.append(err_details);
 536
 537            break;
 538        }
 539
 540        while (arg.name().len > 0) {
 541            const arg_name = arg.name();
 542            // Note: These cases should be in order from longest to shortest, since
 543            //       shorter options that are a substring of a longer one could make
 544            //       the longer option's branch unreachable.
 545            if (std.ascii.startsWithIgnoreCase(arg_name, ":no-preprocess")) {
 546                options.preprocess = .no;
 547                arg.name_offset += ":no-preprocess".len;
 548            } else if (std.ascii.startsWithIgnoreCase(arg_name, ":output-format")) {
 549                const value = arg.value(":output-format".len, arg_i, args) catch {
 550                    var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = arg.missingSpan() };
 551                    try err_details.msg.print(allocator, "missing value after {s}{s} option", .{ arg.prefixSlice(), arg.optionWithoutPrefix(":output-format".len) });
 552                    try diagnostics.append(err_details);
 553                    arg_i += 1;
 554                    break :next_arg;
 555                };
 556                output_format = std.meta.stringToEnum(Options.OutputFormat, value.slice) orelse blk: {
 557                    var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = value.argSpan(arg) };
 558                    try err_details.msg.print(allocator, "invalid output format setting: {s} ", .{value.slice});
 559                    try diagnostics.append(err_details);
 560                    break :blk output_format;
 561                };
 562                output_format_context = .{ .index = arg_i, .option_len = ":output-format".len, .arg = arg, .value = value };
 563                arg_i += value.index_increment;
 564                continue :next_arg;
 565            } else if (std.ascii.startsWithIgnoreCase(arg_name, ":auto-includes")) {
 566                const value = arg.value(":auto-includes".len, arg_i, args) catch {
 567                    var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = arg.missingSpan() };
 568                    try err_details.msg.print(allocator, "missing value after {s}{s} option", .{ arg.prefixSlice(), arg.optionWithoutPrefix(":auto-includes".len) });
 569                    try diagnostics.append(err_details);
 570                    arg_i += 1;
 571                    break :next_arg;
 572                };
 573                options.auto_includes = std.meta.stringToEnum(Options.AutoIncludes, value.slice) orelse blk: {
 574                    var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = value.argSpan(arg) };
 575                    try err_details.msg.print(allocator, "invalid auto includes setting: {s} ", .{value.slice});
 576                    try diagnostics.append(err_details);
 577                    break :blk options.auto_includes;
 578                };
 579                arg_i += value.index_increment;
 580                continue :next_arg;
 581            } else if (std.ascii.startsWithIgnoreCase(arg_name, ":input-format")) {
 582                const value = arg.value(":input-format".len, arg_i, args) catch {
 583                    var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = arg.missingSpan() };
 584                    try err_details.msg.print(allocator, "missing value after {s}{s} option", .{ arg.prefixSlice(), arg.optionWithoutPrefix(":input-format".len) });
 585                    try diagnostics.append(err_details);
 586                    arg_i += 1;
 587                    break :next_arg;
 588                };
 589                input_format = std.meta.stringToEnum(Options.InputFormat, value.slice) orelse blk: {
 590                    var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = value.argSpan(arg) };
 591                    try err_details.msg.print(allocator, "invalid input format setting: {s} ", .{value.slice});
 592                    try diagnostics.append(err_details);
 593                    break :blk input_format;
 594                };
 595                input_format_context = .{ .index = arg_i, .option_len = ":input-format".len, .arg = arg, .value = value };
 596                arg_i += value.index_increment;
 597                continue :next_arg;
 598            } else if (std.ascii.startsWithIgnoreCase(arg_name, ":depfile-fmt")) {
 599                const value = arg.value(":depfile-fmt".len, arg_i, args) catch {
 600                    var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = arg.missingSpan() };
 601                    try err_details.msg.print(allocator, "missing value after {s}{s} option", .{ arg.prefixSlice(), arg.optionWithoutPrefix(":depfile-fmt".len) });
 602                    try diagnostics.append(err_details);
 603                    arg_i += 1;
 604                    break :next_arg;
 605                };
 606                options.depfile_fmt = std.meta.stringToEnum(Options.DepfileFormat, value.slice) orelse blk: {
 607                    var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = value.argSpan(arg) };
 608                    try err_details.msg.print(allocator, "invalid depfile format setting: {s} ", .{value.slice});
 609                    try diagnostics.append(err_details);
 610                    break :blk options.depfile_fmt;
 611                };
 612                arg_i += value.index_increment;
 613                continue :next_arg;
 614            } else if (std.ascii.startsWithIgnoreCase(arg_name, ":depfile")) {
 615                const value = arg.value(":depfile".len, arg_i, args) catch {
 616                    var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = arg.missingSpan() };
 617                    try err_details.msg.print(allocator, "missing value after {s}{s} option", .{ arg.prefixSlice(), arg.optionWithoutPrefix(":depfile".len) });
 618                    try diagnostics.append(err_details);
 619                    arg_i += 1;
 620                    break :next_arg;
 621                };
 622                if (options.depfile_path) |overwritten_path| {
 623                    allocator.free(overwritten_path);
 624                    options.depfile_path = null;
 625                }
 626                const path = try allocator.dupe(u8, value.slice);
 627                errdefer allocator.free(path);
 628                options.depfile_path = path;
 629                depfile_context = .{ .index = arg_i, .option_len = ":depfile".len, .arg = arg, .value = value };
 630                arg_i += value.index_increment;
 631                continue :next_arg;
 632            } else if (std.ascii.startsWithIgnoreCase(arg_name, ":target")) {
 633                const value = arg.value(":target".len, arg_i, args) catch {
 634                    var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = arg.missingSpan() };
 635                    try err_details.msg.print(allocator, "missing value after {s}{s} option", .{ arg.prefixSlice(), arg.optionWithoutPrefix(":target".len) });
 636                    try diagnostics.append(err_details);
 637                    arg_i += 1;
 638                    break :next_arg;
 639                };
 640                // Take the substring up to the first dash so that a full target triple
 641                // can be used, e.g. x86_64-windows-gnu becomes x86_64
 642                var target_it = std.mem.splitScalar(u8, value.slice, '-');
 643                const arch_str = target_it.first();
 644                const arch = cvtres.supported_targets.Arch.fromStringIgnoreCase(arch_str) orelse {
 645                    var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = value.argSpan(arg) };
 646                    try err_details.msg.print(allocator, "invalid or unsupported target architecture: {s}", .{arch_str});
 647                    try diagnostics.append(err_details);
 648                    arg_i += value.index_increment;
 649                    continue :next_arg;
 650                };
 651                options.coff_options.target = arch.toCoffMachineType();
 652                arg_i += value.index_increment;
 653                continue :next_arg;
 654            } else if (std.ascii.startsWithIgnoreCase(arg_name, "nologo")) {
 655                // No-op, we don't display any 'logo' to suppress
 656                arg.name_offset += "nologo".len;
 657            } else if (std.ascii.startsWithIgnoreCase(arg_name, ":debug")) {
 658                options.debug = true;
 659                arg.name_offset += ":debug".len;
 660            }
 661            // Unsupported LCX/LCE options that need a value (within the same arg only)
 662            else if (std.ascii.startsWithIgnoreCase(arg_name, "tp:")) {
 663                const rest = arg.full[arg.name_offset + 3 ..];
 664                if (rest.len == 0) {
 665                    var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = .{
 666                        .name_offset = arg.name_offset,
 667                        .prefix_len = arg.prefixSlice().len,
 668                        .value_offset = arg.name_offset + 3,
 669                    } };
 670                    try err_details.msg.print(allocator, "missing value for {s}{s} option", .{ arg.prefixSlice(), arg.optionWithoutPrefix(3) });
 671                    try diagnostics.append(err_details);
 672                }
 673                var err_details = Diagnostics.ErrorDetails{ .type = .err, .arg_index = arg_i, .arg_span = arg.optionAndAfterSpan() };
 674                try err_details.msg.print(allocator, "the {s}{s} option is unsupported", .{ arg.prefixSlice(), arg.optionWithoutPrefix(3) });
 675                try diagnostics.append(err_details);
 676                arg_i += 1;
 677                continue :next_arg;
 678            }
 679            // Unsupported LCX/LCE options that need a value
 680            else if (std.ascii.startsWithIgnoreCase(arg_name, "tn")) {
 681                const value = arg.value(2, arg_i, args) catch no_value: {
 682                    var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = arg.missingSpan() };
 683                    try err_details.msg.print(allocator, "missing value after {s}{s} option", .{ arg.prefixSlice(), arg.optionWithoutPrefix(2) });
 684                    try diagnostics.append(err_details);
 685                    // dummy zero-length slice starting where the value would have been
 686                    const value_start = arg.name_offset + 2;
 687                    break :no_value Arg.Value{ .slice = arg.full[value_start..value_start] };
 688                };
 689                var err_details = Diagnostics.ErrorDetails{ .type = .err, .arg_index = arg_i, .arg_span = arg.optionAndAfterSpan() };
 690                try err_details.msg.print(allocator, "the {s}{s} option is unsupported", .{ arg.prefixSlice(), arg.optionWithoutPrefix(2) });
 691                try diagnostics.append(err_details);
 692                arg_i += value.index_increment;
 693                continue :next_arg;
 694            }
 695            // Unsupported MUI options that need a value
 696            else if (std.ascii.startsWithIgnoreCase(arg_name, "fm") or
 697                std.ascii.startsWithIgnoreCase(arg_name, "gn") or
 698                std.ascii.startsWithIgnoreCase(arg_name, "g2"))
 699            {
 700                const value = arg.value(2, arg_i, args) catch no_value: {
 701                    var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = arg.missingSpan() };
 702                    try err_details.msg.print(allocator, "missing value after {s}{s} option", .{ arg.prefixSlice(), arg.optionWithoutPrefix(2) });
 703                    try diagnostics.append(err_details);
 704                    // dummy zero-length slice starting where the value would have been
 705                    const value_start = arg.name_offset + 2;
 706                    break :no_value Arg.Value{ .slice = arg.full[value_start..value_start] };
 707                };
 708                var err_details = Diagnostics.ErrorDetails{ .type = .err, .arg_index = arg_i, .arg_span = arg.optionAndAfterSpan() };
 709                try err_details.msg.print(allocator, "the {s}{s} option is unsupported", .{ arg.prefixSlice(), arg.optionWithoutPrefix(2) });
 710                try diagnostics.append(err_details);
 711                arg_i += value.index_increment;
 712                continue :next_arg;
 713            }
 714            // Unsupported MUI options that do not need a value
 715            else if (std.ascii.startsWithIgnoreCase(arg_name, "g1")) {
 716                var err_details = Diagnostics.ErrorDetails{ .type = .err, .arg_index = arg_i, .arg_span = arg.optionSpan(2) };
 717                try err_details.msg.print(allocator, "the {s}{s} option is unsupported", .{ arg.prefixSlice(), arg.optionWithoutPrefix(2) });
 718                try diagnostics.append(err_details);
 719                arg.name_offset += 2;
 720            }
 721            // Unsupported LCX/LCE options that do not need a value
 722            else if (std.ascii.startsWithIgnoreCase(arg_name, "tm") or
 723                std.ascii.startsWithIgnoreCase(arg_name, "tc") or
 724                std.ascii.startsWithIgnoreCase(arg_name, "tw") or
 725                std.ascii.startsWithIgnoreCase(arg_name, "te") or
 726                std.ascii.startsWithIgnoreCase(arg_name, "ti") or
 727                std.ascii.startsWithIgnoreCase(arg_name, "ta"))
 728            {
 729                var err_details = Diagnostics.ErrorDetails{ .type = .err, .arg_index = arg_i, .arg_span = arg.optionSpan(2) };
 730                try err_details.msg.print(allocator, "the {s}{s} option is unsupported", .{ arg.prefixSlice(), arg.optionWithoutPrefix(2) });
 731                try diagnostics.append(err_details);
 732                arg.name_offset += 2;
 733            } else if (std.ascii.startsWithIgnoreCase(arg_name, "fo")) {
 734                const value = arg.value(2, arg_i, args) catch {
 735                    var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = arg.missingSpan() };
 736                    try err_details.msg.print(allocator, "missing output path after {s}{s} option", .{ arg.prefixSlice(), arg.optionWithoutPrefix(2) });
 737                    try diagnostics.append(err_details);
 738                    arg_i += 1;
 739                    break :next_arg;
 740                };
 741                output_filename_context = .{ .arg = .{ .index = arg_i, .option_len = "fo".len, .arg = arg, .value = value } };
 742                output_filename = value.slice;
 743                arg_i += value.index_increment;
 744                continue :next_arg;
 745            } else if (std.ascii.startsWithIgnoreCase(arg_name, "sl")) {
 746                const value = arg.value(2, arg_i, args) catch {
 747                    var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = arg.missingSpan() };
 748                    try err_details.msg.print(allocator, "missing language tag after {s}{s} option", .{ arg.prefixSlice(), arg.optionWithoutPrefix(2) });
 749                    try diagnostics.append(err_details);
 750                    arg_i += 1;
 751                    break :next_arg;
 752                };
 753                const percent_str = value.slice;
 754                const percent: u32 = parsePercent(percent_str) catch {
 755                    var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = value.argSpan(arg) };
 756                    try err_details.msg.print(allocator, "invalid percent format '{s}'", .{percent_str});
 757                    try diagnostics.append(err_details);
 758                    var note_details = Diagnostics.ErrorDetails{ .type = .note, .print_args = false, .arg_index = arg_i };
 759                    try note_details.msg.appendSlice(allocator, "string length percent must be an integer between 1 and 100 (inclusive)");
 760                    try diagnostics.append(note_details);
 761                    arg_i += value.index_increment;
 762                    continue :next_arg;
 763                };
 764                if (percent == 0 or percent > 100) {
 765                    var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = value.argSpan(arg) };
 766                    try err_details.msg.print(allocator, "percent out of range: {} (parsed from '{s}')", .{ percent, percent_str });
 767                    try diagnostics.append(err_details);
 768                    var note_details = Diagnostics.ErrorDetails{ .type = .note, .print_args = false, .arg_index = arg_i };
 769                    try note_details.msg.appendSlice(allocator, "string length percent must be an integer between 1 and 100 (inclusive)");
 770                    try diagnostics.append(note_details);
 771                    arg_i += value.index_increment;
 772                    continue :next_arg;
 773                }
 774                const percent_float = @as(f32, @floatFromInt(percent)) / 100;
 775                options.max_string_literal_codepoints = @intFromFloat(percent_float * max_string_literal_length_100_percent);
 776                arg_i += value.index_increment;
 777                continue :next_arg;
 778            } else if (std.ascii.startsWithIgnoreCase(arg_name, "ln")) {
 779                const value = arg.value(2, arg_i, args) catch {
 780                    var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = arg.missingSpan() };
 781                    try err_details.msg.print(allocator, "missing language tag after {s}{s} option", .{ arg.prefixSlice(), arg.optionWithoutPrefix(2) });
 782                    try diagnostics.append(err_details);
 783                    arg_i += 1;
 784                    break :next_arg;
 785                };
 786                const tag = value.slice;
 787                options.default_language_id = lang.tagToInt(tag) catch {
 788                    var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = value.argSpan(arg) };
 789                    try err_details.msg.print(allocator, "invalid language tag: {s}", .{tag});
 790                    try diagnostics.append(err_details);
 791                    arg_i += value.index_increment;
 792                    continue :next_arg;
 793                };
 794                if (options.default_language_id.? == lang.LOCALE_CUSTOM_UNSPECIFIED) {
 795                    var err_details = Diagnostics.ErrorDetails{ .type = .warning, .arg_index = arg_i, .arg_span = value.argSpan(arg) };
 796                    try err_details.msg.print(allocator, "language tag '{s}' does not have an assigned ID so it will be resolved to LOCALE_CUSTOM_UNSPECIFIED (id=0x{x})", .{ tag, lang.LOCALE_CUSTOM_UNSPECIFIED });
 797                    try diagnostics.append(err_details);
 798                }
 799                arg_i += value.index_increment;
 800                continue :next_arg;
 801            } else if (std.ascii.startsWithIgnoreCase(arg_name, "l")) {
 802                const value = arg.value(1, arg_i, args) catch {
 803                    var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = arg.missingSpan() };
 804                    try err_details.msg.print(allocator, "missing language ID after {s}{s} option", .{ arg.prefixSlice(), arg.optionWithoutPrefix(1) });
 805                    try diagnostics.append(err_details);
 806                    arg_i += 1;
 807                    break :next_arg;
 808                };
 809                const num_str = value.slice;
 810                options.default_language_id = lang.parseInt(num_str) catch {
 811                    var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = value.argSpan(arg) };
 812                    try err_details.msg.print(allocator, "invalid language ID: {s}", .{num_str});
 813                    try diagnostics.append(err_details);
 814                    arg_i += value.index_increment;
 815                    continue :next_arg;
 816                };
 817                arg_i += value.index_increment;
 818                continue :next_arg;
 819            } else if (std.ascii.startsWithIgnoreCase(arg_name, "h") or std.mem.startsWith(u8, arg_name, "?")) {
 820                options.print_help_and_exit = true;
 821                // If there's been an error to this point, then we still want to fail
 822                if (diagnostics.hasError()) return error.ParseError;
 823                return options;
 824            }
 825            // 1 char unsupported MUI options that need a value
 826            else if (std.ascii.startsWithIgnoreCase(arg_name, "q") or
 827                std.ascii.startsWithIgnoreCase(arg_name, "g"))
 828            {
 829                const value = arg.value(1, arg_i, args) catch no_value: {
 830                    var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = arg.missingSpan() };
 831                    try err_details.msg.print(allocator, "missing value after {s}{s} option", .{ arg.prefixSlice(), arg.optionWithoutPrefix(1) });
 832                    try diagnostics.append(err_details);
 833                    // dummy zero-length slice starting where the value would have been
 834                    const value_start = arg.name_offset + 1;
 835                    break :no_value Arg.Value{ .slice = arg.full[value_start..value_start] };
 836                };
 837                var err_details = Diagnostics.ErrorDetails{ .type = .err, .arg_index = arg_i, .arg_span = arg.optionAndAfterSpan() };
 838                try err_details.msg.print(allocator, "the {s}{s} option is unsupported", .{ arg.prefixSlice(), arg.optionWithoutPrefix(1) });
 839                try diagnostics.append(err_details);
 840                arg_i += value.index_increment;
 841                continue :next_arg;
 842            }
 843            // Undocumented (and unsupported) options that need a value
 844            //  /z has to do something with font substitution
 845            //  /s has something to do with HWB resources being inserted into the .res
 846            else if (std.ascii.startsWithIgnoreCase(arg_name, "z") or
 847                std.ascii.startsWithIgnoreCase(arg_name, "s"))
 848            {
 849                const value = arg.value(1, arg_i, args) catch no_value: {
 850                    var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = arg.missingSpan() };
 851                    try err_details.msg.print(allocator, "missing value after {s}{s} option", .{ arg.prefixSlice(), arg.optionWithoutPrefix(1) });
 852                    try diagnostics.append(err_details);
 853                    // dummy zero-length slice starting where the value would have been
 854                    const value_start = arg.name_offset + 1;
 855                    break :no_value Arg.Value{ .slice = arg.full[value_start..value_start] };
 856                };
 857                var err_details = Diagnostics.ErrorDetails{ .type = .err, .arg_index = arg_i, .arg_span = arg.optionAndAfterSpan() };
 858                try err_details.msg.print(allocator, "the {s}{s} option is unsupported", .{ arg.prefixSlice(), arg.optionWithoutPrefix(1) });
 859                try diagnostics.append(err_details);
 860                arg_i += value.index_increment;
 861                continue :next_arg;
 862            }
 863            // 1 char unsupported LCX/LCE options that do not need a value
 864            else if (std.ascii.startsWithIgnoreCase(arg_name, "t")) {
 865                var err_details = Diagnostics.ErrorDetails{ .type = .err, .arg_index = arg_i, .arg_span = arg.optionSpan(1) };
 866                try err_details.msg.print(allocator, "the {s}{s} option is unsupported", .{ arg.prefixSlice(), arg.optionWithoutPrefix(1) });
 867                try diagnostics.append(err_details);
 868                arg.name_offset += 1;
 869            } else if (std.ascii.startsWithIgnoreCase(arg_name, "c")) {
 870                const value = arg.value(1, arg_i, args) catch {
 871                    var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = arg.missingSpan() };
 872                    try err_details.msg.print(allocator, "missing code page ID after {s}{s} option", .{ arg.prefixSlice(), arg.optionWithoutPrefix(1) });
 873                    try diagnostics.append(err_details);
 874                    arg_i += 1;
 875                    break :next_arg;
 876                };
 877                const num_str = value.slice;
 878                const code_page_id = std.fmt.parseUnsigned(u16, num_str, 10) catch {
 879                    var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = value.argSpan(arg) };
 880                    try err_details.msg.print(allocator, "invalid code page ID: {s}", .{num_str});
 881                    try diagnostics.append(err_details);
 882                    arg_i += value.index_increment;
 883                    continue :next_arg;
 884                };
 885                options.default_code_page = code_pages.getByIdentifierEnsureSupported(code_page_id) catch |err| switch (err) {
 886                    error.InvalidCodePage => {
 887                        var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = value.argSpan(arg) };
 888                        try err_details.msg.print(allocator, "invalid or unknown code page ID: {}", .{code_page_id});
 889                        try diagnostics.append(err_details);
 890                        arg_i += value.index_increment;
 891                        continue :next_arg;
 892                    },
 893                    error.UnsupportedCodePage => {
 894                        var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = value.argSpan(arg) };
 895                        try err_details.msg.print(allocator, "unsupported code page: {s} (id={})", .{
 896                            @tagName(code_pages.getByIdentifier(code_page_id) catch unreachable),
 897                            code_page_id,
 898                        });
 899                        try diagnostics.append(err_details);
 900                        arg_i += value.index_increment;
 901                        continue :next_arg;
 902                    },
 903                };
 904                arg_i += value.index_increment;
 905                continue :next_arg;
 906            } else if (std.ascii.startsWithIgnoreCase(arg_name, "v")) {
 907                options.verbose = true;
 908                arg.name_offset += 1;
 909            } else if (std.ascii.startsWithIgnoreCase(arg_name, "x")) {
 910                options.ignore_include_env_var = true;
 911                arg.name_offset += 1;
 912            } else if (std.ascii.startsWithIgnoreCase(arg_name, "p")) {
 913                options.preprocess = .only;
 914                preprocess_only_context = .{ .index = arg_i, .option_len = "p".len, .arg = arg, .value = undefined };
 915                arg.name_offset += 1;
 916            } else if (std.ascii.startsWithIgnoreCase(arg_name, "i")) {
 917                const value = arg.value(1, arg_i, args) catch {
 918                    var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = arg.missingSpan() };
 919                    try err_details.msg.print(allocator, "missing include path after {s}{s} option", .{ arg.prefixSlice(), arg.optionWithoutPrefix(1) });
 920                    try diagnostics.append(err_details);
 921                    arg_i += 1;
 922                    break :next_arg;
 923                };
 924                const path = value.slice;
 925                const duped = try allocator.dupe(u8, path);
 926                errdefer allocator.free(duped);
 927                try options.extra_include_paths.append(options.allocator, duped);
 928                arg_i += value.index_increment;
 929                continue :next_arg;
 930            } else if (std.ascii.startsWithIgnoreCase(arg_name, "r")) {
 931                // From https://learn.microsoft.com/en-us/windows/win32/menurc/using-rc-the-rc-command-line-
 932                // "Ignored. Provided for compatibility with existing makefiles."
 933                arg.name_offset += 1;
 934            } else if (std.ascii.startsWithIgnoreCase(arg_name, "n")) {
 935                options.null_terminate_string_table_strings = true;
 936                arg.name_offset += 1;
 937            } else if (std.ascii.startsWithIgnoreCase(arg_name, "y")) {
 938                options.silent_duplicate_control_ids = true;
 939                arg.name_offset += 1;
 940            } else if (std.ascii.startsWithIgnoreCase(arg_name, "w")) {
 941                options.warn_instead_of_error_on_invalid_code_page = true;
 942                arg.name_offset += 1;
 943            } else if (std.ascii.startsWithIgnoreCase(arg_name, "a")) {
 944                // Undocumented option with unknown function
 945                // TODO: More investigation to figure out what it does (if anything)
 946                var err_details = Diagnostics.ErrorDetails{ .type = .warning, .arg_index = arg_i, .arg_span = arg.optionSpan(1) };
 947                try err_details.msg.print(allocator, "option {s}{s} has no effect (it is undocumented and its function is unknown in the Win32 RC compiler)", .{ arg.prefixSlice(), arg.optionWithoutPrefix(1) });
 948                try diagnostics.append(err_details);
 949                arg.name_offset += 1;
 950            } else if (std.ascii.startsWithIgnoreCase(arg_name, "d")) {
 951                const value = arg.value(1, arg_i, args) catch {
 952                    var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = arg.missingSpan() };
 953                    try err_details.msg.print(allocator, "missing symbol to define after {s}{s} option", .{ arg.prefixSlice(), arg.optionWithoutPrefix(1) });
 954                    try diagnostics.append(err_details);
 955                    arg_i += 1;
 956                    break :next_arg;
 957                };
 958                var tokenizer = std.mem.tokenizeScalar(u8, value.slice, '=');
 959                // guaranteed to exist since an empty value.slice would invoke
 960                // the 'missing symbol to define' branch above
 961                const symbol = tokenizer.next().?;
 962                const symbol_value = tokenizer.next() orelse "1";
 963
 964                if (isValidIdentifier(symbol)) {
 965                    try options.define(symbol, symbol_value);
 966                } else {
 967                    var err_details = Diagnostics.ErrorDetails{ .type = .warning, .arg_index = arg_i, .arg_span = value.argSpan(arg) };
 968                    try err_details.msg.print(allocator, "symbol \"{s}\" is not a valid identifier and therefore cannot be defined", .{symbol});
 969                    try diagnostics.append(err_details);
 970                }
 971                arg_i += value.index_increment;
 972                continue :next_arg;
 973            } else if (std.ascii.startsWithIgnoreCase(arg_name, "u")) {
 974                const value = arg.value(1, arg_i, args) catch {
 975                    var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = arg.missingSpan() };
 976                    try err_details.msg.print(allocator, "missing symbol to undefine after {s}{s} option", .{ arg.prefixSlice(), arg.optionWithoutPrefix(1) });
 977                    try diagnostics.append(err_details);
 978                    arg_i += 1;
 979                    break :next_arg;
 980                };
 981                const symbol = value.slice;
 982                if (isValidIdentifier(symbol)) {
 983                    try options.undefine(symbol);
 984                } else {
 985                    var err_details = Diagnostics.ErrorDetails{ .type = .warning, .arg_index = arg_i, .arg_span = value.argSpan(arg) };
 986                    try err_details.msg.print(allocator, "symbol \"{s}\" is not a valid identifier and therefore cannot be undefined", .{symbol});
 987                    try diagnostics.append(err_details);
 988                }
 989                arg_i += value.index_increment;
 990                continue :next_arg;
 991            } else {
 992                var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i, .arg_span = arg.optionAndAfterSpan() };
 993                try err_details.msg.print(allocator, "invalid option: {s}{s}", .{ arg.prefixSlice(), arg.name() });
 994                try diagnostics.append(err_details);
 995                arg_i += 1;
 996                continue :next_arg;
 997            }
 998        } else {
 999            // The while loop exited via its conditional, meaning we are done with
1000            // the current arg and can move on the the next
1001            arg_i += 1;
1002            continue;
1003        }
1004    }
1005
1006    const positionals = args[arg_i..];
1007
1008    if (positionals.len == 0) {
1009        var err_details = Diagnostics.ErrorDetails{ .print_args = false, .arg_index = arg_i };
1010        try err_details.msg.appendSlice(allocator, "missing input filename");
1011        try diagnostics.append(err_details);
1012
1013        if (args.len > 0) {
1014            const last_arg = args[args.len - 1];
1015            if (arg_i > 0 and last_arg.len > 0 and last_arg[0] == '/' and isSupportedInputExtension(std.fs.path.extension(last_arg))) {
1016                var note_details = Diagnostics.ErrorDetails{ .type = .note, .print_args = true, .arg_index = arg_i - 1 };
1017                try note_details.msg.appendSlice(allocator, "if this argument was intended to be the input filename, adding -- in front of it will exclude it from option parsing");
1018                try diagnostics.append(note_details);
1019            }
1020        }
1021
1022        // This is a fatal enough problem to justify an early return, since
1023        // things after this rely on the value of the input filename.
1024        return error.ParseError;
1025    }
1026    options.input_source = .{ .filename = try allocator.dupe(u8, positionals[0]) };
1027    input_filename_arg_i = arg_i;
1028
1029    const InputFormatSource = enum {
1030        inferred_from_input_filename,
1031        input_format_arg,
1032    };
1033
1034    var input_format_source: InputFormatSource = undefined;
1035    if (input_format == null) {
1036        const ext = std.fs.path.extension(options.input_source.filename);
1037        if (std.ascii.eqlIgnoreCase(ext, ".res")) {
1038            input_format = .res;
1039        } else if (std.ascii.eqlIgnoreCase(ext, ".rcpp")) {
1040            input_format = .rcpp;
1041        } else {
1042            input_format = .rc;
1043        }
1044        input_format_source = .inferred_from_input_filename;
1045    } else {
1046        input_format_source = .input_format_arg;
1047    }
1048
1049    if (positionals.len > 1) {
1050        if (output_filename != null) {
1051            var err_details = Diagnostics.ErrorDetails{ .arg_index = arg_i + 1 };
1052            try err_details.msg.appendSlice(allocator, "output filename already specified");
1053            try diagnostics.append(err_details);
1054            var note_details = Diagnostics.ErrorDetails{
1055                .type = .note,
1056                .arg_index = output_filename_context.arg.index,
1057                .arg_span = output_filename_context.arg.value.argSpan(output_filename_context.arg.arg),
1058            };
1059            try note_details.msg.appendSlice(allocator, "output filename previously specified here");
1060            try diagnostics.append(note_details);
1061        } else {
1062            output_filename = positionals[1];
1063            output_filename_context = .{ .positional = arg_i + 1 };
1064        }
1065    }
1066
1067    const OutputFormatSource = enum {
1068        inferred_from_input_filename,
1069        inferred_from_output_filename,
1070        output_format_arg,
1071        unable_to_infer_from_input_filename,
1072        unable_to_infer_from_output_filename,
1073        inferred_from_preprocess_only,
1074    };
1075
1076    var output_format_source: OutputFormatSource = undefined;
1077    if (output_filename == null) {
1078        if (output_format == null) {
1079            output_format_source = .inferred_from_input_filename;
1080            const input_ext = std.fs.path.extension(options.input_source.filename);
1081            if (std.ascii.eqlIgnoreCase(input_ext, ".res")) {
1082                output_format = .coff;
1083            } else if (options.preprocess == .only and (input_format.? == .rc or std.ascii.eqlIgnoreCase(input_ext, ".rc"))) {
1084                output_format = .rcpp;
1085                output_format_source = .inferred_from_preprocess_only;
1086            } else {
1087                if (!std.ascii.eqlIgnoreCase(input_ext, ".res")) {
1088                    output_format_source = .unable_to_infer_from_input_filename;
1089                }
1090                output_format = .res;
1091            }
1092        } else {
1093            output_format_source = .output_format_arg;
1094        }
1095        options.output_source = .{ .filename = try filepathWithExtension(allocator, options.input_source.filename, output_format.?.extension()) };
1096    } else {
1097        options.output_source = .{ .filename = try allocator.dupe(u8, output_filename.?) };
1098        if (output_format == null) {
1099            output_format_source = .inferred_from_output_filename;
1100            const ext = std.fs.path.extension(options.output_source.filename);
1101            if (std.ascii.eqlIgnoreCase(ext, ".obj") or std.ascii.eqlIgnoreCase(ext, ".o")) {
1102                output_format = .coff;
1103            } else if (std.ascii.eqlIgnoreCase(ext, ".rcpp")) {
1104                output_format = .rcpp;
1105            } else {
1106                if (!std.ascii.eqlIgnoreCase(ext, ".res")) {
1107                    output_format_source = .unable_to_infer_from_output_filename;
1108                }
1109                output_format = .res;
1110            }
1111        } else {
1112            output_format_source = .output_format_arg;
1113        }
1114    }
1115
1116    options.input_format = input_format.?;
1117    options.output_format = output_format.?;
1118
1119    // Check for incompatible options
1120    var print_input_format_source_note: bool = false;
1121    var print_output_format_source_note: bool = false;
1122    if (options.depfile_path != null and (options.input_format == .res or options.output_format == .rcpp)) {
1123        var err_details = Diagnostics.ErrorDetails{ .type = .warning, .arg_index = depfile_context.index, .arg_span = depfile_context.value.argSpan(depfile_context.arg) };
1124        if (options.input_format == .res) {
1125            try err_details.msg.print(allocator, "the {s}{s} option was ignored because the input format is '{s}'", .{
1126                depfile_context.arg.prefixSlice(),
1127                depfile_context.arg.optionWithoutPrefix(depfile_context.option_len),
1128                @tagName(options.input_format),
1129            });
1130            print_input_format_source_note = true;
1131        } else if (options.output_format == .rcpp) {
1132            try err_details.msg.print(allocator, "the {s}{s} option was ignored because the output format is '{s}'", .{
1133                depfile_context.arg.prefixSlice(),
1134                depfile_context.arg.optionWithoutPrefix(depfile_context.option_len),
1135                @tagName(options.output_format),
1136            });
1137            print_output_format_source_note = true;
1138        }
1139        try diagnostics.append(err_details);
1140    }
1141    if (!isSupportedTransformation(options.input_format, options.output_format)) {
1142        var err_details = Diagnostics.ErrorDetails{ .arg_index = input_filename_arg_i, .print_args = false };
1143        try err_details.msg.print(allocator, "input format '{s}' cannot be converted to output format '{s}'", .{ @tagName(options.input_format), @tagName(options.output_format) });
1144        try diagnostics.append(err_details);
1145        print_input_format_source_note = true;
1146        print_output_format_source_note = true;
1147    }
1148    if (options.preprocess == .only and options.output_format != .rcpp) {
1149        var err_details = Diagnostics.ErrorDetails{ .arg_index = preprocess_only_context.index };
1150        try err_details.msg.print(allocator, "the {s}{s} option cannot be used with output format '{s}'", .{
1151            preprocess_only_context.arg.prefixSlice(),
1152            preprocess_only_context.arg.optionWithoutPrefix(preprocess_only_context.option_len),
1153            @tagName(options.output_format),
1154        });
1155        try diagnostics.append(err_details);
1156        print_output_format_source_note = true;
1157    }
1158    if (print_input_format_source_note) {
1159        switch (input_format_source) {
1160            .inferred_from_input_filename => {
1161                var err_details = Diagnostics.ErrorDetails{ .type = .note, .arg_index = input_filename_arg_i };
1162                try err_details.msg.appendSlice(allocator, "the input format was inferred from the input filename");
1163                try diagnostics.append(err_details);
1164            },
1165            .input_format_arg => {
1166                var err_details = Diagnostics.ErrorDetails{
1167                    .type = .note,
1168                    .arg_index = input_format_context.index,
1169                    .arg_span = input_format_context.value.argSpan(input_format_context.arg),
1170                };
1171                try err_details.msg.appendSlice(allocator, "the input format was specified here");
1172                try diagnostics.append(err_details);
1173            },
1174        }
1175    }
1176    if (print_output_format_source_note) {
1177        switch (output_format_source) {
1178            .inferred_from_input_filename, .unable_to_infer_from_input_filename => {
1179                var err_details = Diagnostics.ErrorDetails{ .type = .note, .arg_index = input_filename_arg_i };
1180                if (output_format_source == .inferred_from_input_filename) {
1181                    try err_details.msg.appendSlice(allocator, "the output format was inferred from the input filename");
1182                } else {
1183                    try err_details.msg.appendSlice(allocator, "the output format was unable to be inferred from the input filename, so the default was used");
1184                }
1185                try diagnostics.append(err_details);
1186            },
1187            .inferred_from_output_filename, .unable_to_infer_from_output_filename => {
1188                var err_details: Diagnostics.ErrorDetails = switch (output_filename_context) {
1189                    .positional => |i| .{ .type = .note, .arg_index = i },
1190                    .arg => |ctx| .{ .type = .note, .arg_index = ctx.index, .arg_span = ctx.value.argSpan(ctx.arg) },
1191                    .unspecified => unreachable,
1192                };
1193                if (output_format_source == .inferred_from_output_filename) {
1194                    try err_details.msg.appendSlice(allocator, "the output format was inferred from the output filename");
1195                } else {
1196                    try err_details.msg.appendSlice(allocator, "the output format was unable to be inferred from the output filename, so the default was used");
1197                }
1198                try diagnostics.append(err_details);
1199            },
1200            .output_format_arg => {
1201                var err_details = Diagnostics.ErrorDetails{
1202                    .type = .note,
1203                    .arg_index = output_format_context.index,
1204                    .arg_span = output_format_context.value.argSpan(output_format_context.arg),
1205                };
1206                try err_details.msg.appendSlice(allocator, "the output format was specified here");
1207                try diagnostics.append(err_details);
1208            },
1209            .inferred_from_preprocess_only => {
1210                var err_details = Diagnostics.ErrorDetails{ .type = .note, .arg_index = preprocess_only_context.index };
1211                try err_details.msg.print(allocator, "the output format was inferred from the usage of the {s}{s} option", .{
1212                    preprocess_only_context.arg.prefixSlice(),
1213                    preprocess_only_context.arg.optionWithoutPrefix(preprocess_only_context.option_len),
1214                });
1215                try diagnostics.append(err_details);
1216            },
1217        }
1218    }
1219
1220    if (diagnostics.hasError()) {
1221        return error.ParseError;
1222    }
1223
1224    // Implied settings from input/output formats
1225    if (options.output_format == .rcpp) options.preprocess = .only;
1226    if (options.input_format == .res) options.output_format = .coff;
1227    if (options.input_format == .rcpp) options.preprocess = .no;
1228
1229    return options;
1230}
1231
1232pub fn filepathWithExtension(allocator: Allocator, path: []const u8, ext: []const u8) ![]const u8 {
1233    var buf: std.ArrayList(u8) = .empty;
1234    errdefer buf.deinit(allocator);
1235    if (std.fs.path.dirname(path)) |dirname| {
1236        var end_pos = dirname.len;
1237        // We want to ensure that we write a path separator at the end, so if the dirname
1238        // doesn't end with a path sep then include the char after the dirname
1239        // which must be a path sep.
1240        if (!std.fs.path.isSep(dirname[dirname.len - 1])) end_pos += 1;
1241        try buf.appendSlice(allocator, path[0..end_pos]);
1242    }
1243    try buf.appendSlice(allocator, std.fs.path.stem(path));
1244    try buf.appendSlice(allocator, ext);
1245    return try buf.toOwnedSlice(allocator);
1246}
1247
1248pub fn isSupportedInputExtension(ext: []const u8) bool {
1249    if (std.ascii.eqlIgnoreCase(ext, ".rc")) return true;
1250    if (std.ascii.eqlIgnoreCase(ext, ".res")) return true;
1251    if (std.ascii.eqlIgnoreCase(ext, ".rcpp")) return true;
1252    return false;
1253}
1254
1255pub fn isSupportedTransformation(input: Options.InputFormat, output: Options.OutputFormat) bool {
1256    return switch (input) {
1257        .rc => switch (output) {
1258            .res => true,
1259            .coff => true,
1260            .rcpp => true,
1261        },
1262        .res => switch (output) {
1263            .res => false,
1264            .coff => true,
1265            .rcpp => false,
1266        },
1267        .rcpp => switch (output) {
1268            .res => true,
1269            .coff => true,
1270            .rcpp => false,
1271        },
1272    };
1273}
1274
1275/// Returns true if the str is a valid C identifier for use in a #define/#undef macro
1276pub fn isValidIdentifier(str: []const u8) bool {
1277    for (str, 0..) |c, i| switch (c) {
1278        '0'...'9' => if (i == 0) return false,
1279        'a'...'z', 'A'...'Z', '_' => {},
1280        else => return false,
1281    };
1282    return true;
1283}
1284
1285/// This function is specific to how the Win32 RC command line interprets
1286/// max string literal length percent.
1287/// - Wraps on overflow of u32
1288/// - Stops parsing on any invalid hexadecimal digits
1289/// - Errors if a digit is not the first char
1290/// - `-` (negative) prefix is allowed
1291pub fn parsePercent(str: []const u8) error{InvalidFormat}!u32 {
1292    var result: u32 = 0;
1293    const radix: u8 = 10;
1294    var buf = str;
1295
1296    const Prefix = enum { none, minus };
1297    var prefix: Prefix = .none;
1298    switch (buf[0]) {
1299        '-' => {
1300            prefix = .minus;
1301            buf = buf[1..];
1302        },
1303        else => {},
1304    }
1305
1306    for (buf, 0..) |c, i| {
1307        const digit = switch (c) {
1308            // On invalid digit for the radix, just stop parsing but don't fail
1309            '0'...'9' => std.fmt.charToDigit(c, radix) catch break,
1310            else => {
1311                // First digit must be valid
1312                if (i == 0) {
1313                    return error.InvalidFormat;
1314                }
1315                break;
1316            },
1317        };
1318
1319        if (result != 0) {
1320            result *%= radix;
1321        }
1322        result +%= digit;
1323    }
1324
1325    switch (prefix) {
1326        .none => {},
1327        .minus => result = 0 -% result,
1328    }
1329
1330    return result;
1331}
1332
1333test parsePercent {
1334    try std.testing.expectEqual(@as(u32, 16), try parsePercent("16"));
1335    try std.testing.expectEqual(@as(u32, 0), try parsePercent("0x1A"));
1336    try std.testing.expectEqual(@as(u32, 0x1), try parsePercent("1zzzz"));
1337    try std.testing.expectEqual(@as(u32, 0xffffffff), try parsePercent("-1"));
1338    try std.testing.expectEqual(@as(u32, 0xfffffff0), try parsePercent("-16"));
1339    try std.testing.expectEqual(@as(u32, 1), try parsePercent("4294967297"));
1340    try std.testing.expectError(error.InvalidFormat, parsePercent("--1"));
1341    try std.testing.expectError(error.InvalidFormat, parsePercent("ha"));
1342    try std.testing.expectError(error.InvalidFormat, parsePercent("¹"));
1343    try std.testing.expectError(error.InvalidFormat, parsePercent("~1"));
1344}
1345
1346pub fn renderErrorMessage(writer: *std.Io.Writer, config: std.Io.tty.Config, err_details: Diagnostics.ErrorDetails, args: []const []const u8) !void {
1347    try config.setColor(writer, .dim);
1348    try writer.writeAll("<cli>");
1349    try config.setColor(writer, .reset);
1350    try config.setColor(writer, .bold);
1351    try writer.writeAll(": ");
1352    switch (err_details.type) {
1353        .err => {
1354            try config.setColor(writer, .red);
1355            try writer.writeAll("error: ");
1356        },
1357        .warning => {
1358            try config.setColor(writer, .yellow);
1359            try writer.writeAll("warning: ");
1360        },
1361        .note => {
1362            try config.setColor(writer, .cyan);
1363            try writer.writeAll("note: ");
1364        },
1365    }
1366    try config.setColor(writer, .reset);
1367    try config.setColor(writer, .bold);
1368    try writer.writeAll(err_details.msg.items);
1369    try writer.writeByte('\n');
1370    try config.setColor(writer, .reset);
1371
1372    if (!err_details.print_args) {
1373        try writer.writeByte('\n');
1374        return;
1375    }
1376
1377    try config.setColor(writer, .dim);
1378    const prefix = " ... ";
1379    try writer.writeAll(prefix);
1380    try config.setColor(writer, .reset);
1381
1382    const arg_with_name = args[err_details.arg_index];
1383    const prefix_slice = arg_with_name[0..err_details.arg_span.prefix_len];
1384    const before_name_slice = arg_with_name[err_details.arg_span.prefix_len..err_details.arg_span.name_offset];
1385    var name_slice = arg_with_name[err_details.arg_span.name_offset..];
1386    if (err_details.arg_span.name_len > 0) name_slice.len = err_details.arg_span.name_len;
1387    const after_name_slice = arg_with_name[err_details.arg_span.name_offset + name_slice.len ..];
1388
1389    try writer.writeAll(prefix_slice);
1390    if (before_name_slice.len > 0) {
1391        try config.setColor(writer, .dim);
1392        try writer.writeAll(before_name_slice);
1393        try config.setColor(writer, .reset);
1394    }
1395    try writer.writeAll(name_slice);
1396    if (after_name_slice.len > 0) {
1397        try config.setColor(writer, .dim);
1398        try writer.writeAll(after_name_slice);
1399        try config.setColor(writer, .reset);
1400    }
1401
1402    var next_arg_len: usize = 0;
1403    if (err_details.arg_span.point_at_next_arg and err_details.arg_index + 1 < args.len) {
1404        const next_arg = args[err_details.arg_index + 1];
1405        try writer.writeByte(' ');
1406        try writer.writeAll(next_arg);
1407        next_arg_len = next_arg.len;
1408    }
1409
1410    const last_shown_arg_index = if (err_details.arg_span.point_at_next_arg) err_details.arg_index + 1 else err_details.arg_index;
1411    if (last_shown_arg_index + 1 < args.len) {
1412        // special case for when pointing to a missing value within the same arg
1413        // as the name
1414        if (err_details.arg_span.value_offset >= arg_with_name.len) {
1415            try writer.writeByte(' ');
1416        }
1417        try config.setColor(writer, .dim);
1418        try writer.writeAll(" ...");
1419        try config.setColor(writer, .reset);
1420    }
1421    try writer.writeByte('\n');
1422
1423    try config.setColor(writer, .green);
1424    try writer.splatByteAll(' ', prefix.len);
1425    // Special case for when the option is *only* a prefix (e.g. invalid option: -)
1426    if (err_details.arg_span.prefix_len == arg_with_name.len) {
1427        try writer.splatByteAll('^', err_details.arg_span.prefix_len);
1428    } else {
1429        try writer.splatByteAll('~', err_details.arg_span.prefix_len);
1430        try writer.splatByteAll(' ', err_details.arg_span.name_offset - err_details.arg_span.prefix_len);
1431        if (!err_details.arg_span.point_at_next_arg and err_details.arg_span.value_offset == 0) {
1432            try writer.writeByte('^');
1433            try writer.splatByteAll('~', name_slice.len - 1);
1434        } else if (err_details.arg_span.value_offset > 0) {
1435            try writer.splatByteAll('~', err_details.arg_span.value_offset - err_details.arg_span.name_offset);
1436            try writer.writeByte('^');
1437            if (err_details.arg_span.value_offset < arg_with_name.len) {
1438                try writer.splatByteAll('~', arg_with_name.len - err_details.arg_span.value_offset - 1);
1439            }
1440        } else if (err_details.arg_span.point_at_next_arg) {
1441            try writer.splatByteAll('~', arg_with_name.len - err_details.arg_span.name_offset + 1);
1442            try writer.writeByte('^');
1443            if (next_arg_len > 0) {
1444                try writer.splatByteAll('~', next_arg_len - 1);
1445            }
1446        }
1447    }
1448    try writer.writeByte('\n');
1449    try config.setColor(writer, .reset);
1450}
1451
1452fn testParse(args: []const []const u8) !Options {
1453    return (try testParseOutput(args, "")).?;
1454}
1455
1456fn testParseWarning(args: []const []const u8, expected_output: []const u8) !Options {
1457    return (try testParseOutput(args, expected_output)).?;
1458}
1459
1460fn testParseError(args: []const []const u8, expected_output: []const u8) !void {
1461    var maybe_options = try testParseOutput(args, expected_output);
1462    if (maybe_options != null) {
1463        std.debug.print("expected error, got options: {}\n", .{maybe_options.?});
1464        maybe_options.?.deinit();
1465        return error.TestExpectedError;
1466    }
1467}
1468
1469fn testParseOutput(args: []const []const u8, expected_output: []const u8) !?Options {
1470    var diagnostics = Diagnostics.init(std.testing.allocator);
1471    defer diagnostics.deinit();
1472
1473    var output: std.Io.Writer.Allocating = .init(std.testing.allocator);
1474    defer output.deinit();
1475
1476    var options = parse(std.testing.allocator, args, &diagnostics) catch |err| switch (err) {
1477        error.ParseError => {
1478            try diagnostics.renderToWriter(args, &output.writer, .no_color);
1479            try std.testing.expectEqualStrings(expected_output, output.written());
1480            return null;
1481        },
1482        else => |e| return e,
1483    };
1484    errdefer options.deinit();
1485
1486    try diagnostics.renderToWriter(args, &output.writer, .no_color);
1487    try std.testing.expectEqualStrings(expected_output, output.written());
1488    return options;
1489}
1490
1491test "parse errors: basic" {
1492    try testParseError(&.{"/"},
1493        \\<cli>: error: invalid option: /
1494        \\ ... /
1495        \\     ^
1496        \\<cli>: error: missing input filename
1497        \\
1498        \\
1499    );
1500    try testParseError(&.{"/ln"},
1501        \\<cli>: error: missing language tag after /ln option
1502        \\ ... /ln
1503        \\     ~~~~^
1504        \\<cli>: error: missing input filename
1505        \\
1506        \\
1507    );
1508    try testParseError(&.{"-vln"},
1509        \\<cli>: error: missing language tag after -ln option
1510        \\ ... -vln
1511        \\     ~ ~~~^
1512        \\<cli>: error: missing input filename
1513        \\
1514        \\
1515    );
1516    try testParseError(&.{"/_not-an-option"},
1517        \\<cli>: error: invalid option: /_not-an-option
1518        \\ ... /_not-an-option
1519        \\     ~^~~~~~~~~~~~~~
1520        \\<cli>: error: missing input filename
1521        \\
1522        \\
1523    );
1524    try testParseError(&.{"-_not-an-option"},
1525        \\<cli>: error: invalid option: -_not-an-option
1526        \\ ... -_not-an-option
1527        \\     ~^~~~~~~~~~~~~~
1528        \\<cli>: error: missing input filename
1529        \\
1530        \\
1531    );
1532    try testParseError(&.{"--_not-an-option"},
1533        \\<cli>: error: invalid option: --_not-an-option
1534        \\ ... --_not-an-option
1535        \\     ~~^~~~~~~~~~~~~~
1536        \\<cli>: error: missing input filename
1537        \\
1538        \\
1539    );
1540    try testParseError(&.{"/v_not-an-option"},
1541        \\<cli>: error: invalid option: /_not-an-option
1542        \\ ... /v_not-an-option
1543        \\     ~ ^~~~~~~~~~~~~~
1544        \\<cli>: error: missing input filename
1545        \\
1546        \\
1547    );
1548    try testParseError(&.{"-v_not-an-option"},
1549        \\<cli>: error: invalid option: -_not-an-option
1550        \\ ... -v_not-an-option
1551        \\     ~ ^~~~~~~~~~~~~~
1552        \\<cli>: error: missing input filename
1553        \\
1554        \\
1555    );
1556    try testParseError(&.{"--v_not-an-option"},
1557        \\<cli>: error: invalid option: --_not-an-option
1558        \\ ... --v_not-an-option
1559        \\     ~~ ^~~~~~~~~~~~~~
1560        \\<cli>: error: missing input filename
1561        \\
1562        \\
1563    );
1564}
1565
1566test "inferred absolute filepaths" {
1567    {
1568        var options = try testParseWarning(&.{ "/fo", "foo.res", "/home/absolute/path.rc" },
1569            \\<cli>: note: this argument was inferred to be a filepath, so argument parsing was terminated
1570            \\ ... /home/absolute/path.rc
1571            \\     ^~~~~~~~~~~~~~~~~~~~~~
1572            \\
1573        );
1574        defer options.deinit();
1575    }
1576    {
1577        var options = try testParseWarning(&.{ "/home/absolute/path.rc", "foo.res" },
1578            \\<cli>: note: this argument was inferred to be a filepath, so argument parsing was terminated
1579            \\ ... /home/absolute/path.rc ...
1580            \\     ^~~~~~~~~~~~~~~~~~~~~~
1581            \\
1582        );
1583        defer options.deinit();
1584    }
1585    {
1586        // Only the last two arguments are checked, so the /h is parsed as an option
1587        var options = try testParse(&.{ "/home/absolute/path.rc", "foo.rc", "foo.res" });
1588        defer options.deinit();
1589
1590        try std.testing.expect(options.print_help_and_exit);
1591    }
1592    {
1593        var options = try testParse(&.{ "/xvFO/some/absolute/path.res", "foo.rc" });
1594        defer options.deinit();
1595
1596        try std.testing.expectEqual(true, options.verbose);
1597        try std.testing.expectEqual(true, options.ignore_include_env_var);
1598        try std.testing.expectEqualStrings("foo.rc", options.input_source.filename);
1599        try std.testing.expectEqualStrings("/some/absolute/path.res", options.output_source.filename);
1600    }
1601}
1602
1603test "parse errors: /ln" {
1604    try testParseError(&.{ "/ln", "invalid", "foo.rc" },
1605        \\<cli>: error: invalid language tag: invalid
1606        \\ ... /ln invalid ...
1607        \\     ~~~~^~~~~~~
1608        \\
1609    );
1610    try testParseError(&.{ "/lninvalid", "foo.rc" },
1611        \\<cli>: error: invalid language tag: invalid
1612        \\ ... /lninvalid ...
1613        \\     ~~~^~~~~~~
1614        \\
1615    );
1616}
1617
1618test "parse: options" {
1619    {
1620        var options = try testParse(&.{ "/v", "foo.rc" });
1621        defer options.deinit();
1622
1623        try std.testing.expectEqual(true, options.verbose);
1624        try std.testing.expectEqualStrings("foo.rc", options.input_source.filename);
1625        try std.testing.expectEqualStrings("foo.res", options.output_source.filename);
1626    }
1627    {
1628        var options = try testParse(&.{ "/vx", "foo.rc" });
1629        defer options.deinit();
1630
1631        try std.testing.expectEqual(true, options.verbose);
1632        try std.testing.expectEqual(true, options.ignore_include_env_var);
1633        try std.testing.expectEqualStrings("foo.rc", options.input_source.filename);
1634        try std.testing.expectEqualStrings("foo.res", options.output_source.filename);
1635    }
1636    {
1637        var options = try testParse(&.{ "/xv", "foo.rc" });
1638        defer options.deinit();
1639
1640        try std.testing.expectEqual(true, options.verbose);
1641        try std.testing.expectEqual(true, options.ignore_include_env_var);
1642        try std.testing.expectEqualStrings("foo.rc", options.input_source.filename);
1643        try std.testing.expectEqualStrings("foo.res", options.output_source.filename);
1644    }
1645    {
1646        var options = try testParse(&.{ "/xvFObar.res", "foo.rc" });
1647        defer options.deinit();
1648
1649        try std.testing.expectEqual(true, options.verbose);
1650        try std.testing.expectEqual(true, options.ignore_include_env_var);
1651        try std.testing.expectEqualStrings("foo.rc", options.input_source.filename);
1652        try std.testing.expectEqualStrings("bar.res", options.output_source.filename);
1653    }
1654}
1655
1656test "parse: define and undefine" {
1657    {
1658        var options = try testParse(&.{ "/dfoo", "foo.rc" });
1659        defer options.deinit();
1660
1661        const action = options.symbols.get("foo").?;
1662        try std.testing.expectEqualStrings("1", action.define);
1663    }
1664    {
1665        var options = try testParse(&.{ "/dfoo=bar", "/dfoo=baz", "foo.rc" });
1666        defer options.deinit();
1667
1668        const action = options.symbols.get("foo").?;
1669        try std.testing.expectEqualStrings("baz", action.define);
1670    }
1671    {
1672        var options = try testParse(&.{ "/ufoo", "foo.rc" });
1673        defer options.deinit();
1674
1675        const action = options.symbols.get("foo").?;
1676        try std.testing.expectEqual(Options.SymbolAction.undefine, action);
1677    }
1678    {
1679        // Once undefined, future defines are ignored
1680        var options = try testParse(&.{ "/ufoo", "/dfoo", "foo.rc" });
1681        defer options.deinit();
1682
1683        const action = options.symbols.get("foo").?;
1684        try std.testing.expectEqual(Options.SymbolAction.undefine, action);
1685    }
1686    {
1687        // Undefined always takes precedence
1688        var options = try testParse(&.{ "/dfoo", "/ufoo", "/dfoo", "foo.rc" });
1689        defer options.deinit();
1690
1691        const action = options.symbols.get("foo").?;
1692        try std.testing.expectEqual(Options.SymbolAction.undefine, action);
1693    }
1694    {
1695        // Warn + ignore invalid identifiers
1696        var options = try testParseWarning(
1697            &.{ "/dfoo bar", "/u", "0leadingdigit", "foo.rc" },
1698            \\<cli>: warning: symbol "foo bar" is not a valid identifier and therefore cannot be defined
1699            \\ ... /dfoo bar ...
1700            \\     ~~^~~~~~~
1701            \\<cli>: warning: symbol "0leadingdigit" is not a valid identifier and therefore cannot be undefined
1702            \\ ... /u 0leadingdigit ...
1703            \\     ~~~^~~~~~~~~~~~~
1704            \\
1705            ,
1706        );
1707        defer options.deinit();
1708
1709        try std.testing.expectEqual(@as(usize, 0), options.symbols.count());
1710    }
1711}
1712
1713test "parse: /sl" {
1714    try testParseError(&.{ "/sl", "0", "foo.rc" },
1715        \\<cli>: error: percent out of range: 0 (parsed from '0')
1716        \\ ... /sl 0 ...
1717        \\     ~~~~^
1718        \\<cli>: note: string length percent must be an integer between 1 and 100 (inclusive)
1719        \\
1720        \\
1721    );
1722    try testParseError(&.{ "/sl", "abcd", "foo.rc" },
1723        \\<cli>: error: invalid percent format 'abcd'
1724        \\ ... /sl abcd ...
1725        \\     ~~~~^~~~
1726        \\<cli>: note: string length percent must be an integer between 1 and 100 (inclusive)
1727        \\
1728        \\
1729    );
1730    {
1731        var options = try testParse(&.{"foo.rc"});
1732        defer options.deinit();
1733
1734        try std.testing.expectEqual(@as(u15, lex.default_max_string_literal_codepoints), options.max_string_literal_codepoints);
1735    }
1736    {
1737        var options = try testParse(&.{ "/sl100", "foo.rc" });
1738        defer options.deinit();
1739
1740        try std.testing.expectEqual(@as(u15, max_string_literal_length_100_percent), options.max_string_literal_codepoints);
1741    }
1742    {
1743        var options = try testParse(&.{ "-SL33", "foo.rc" });
1744        defer options.deinit();
1745
1746        try std.testing.expectEqual(@as(u15, 2703), options.max_string_literal_codepoints);
1747    }
1748    {
1749        var options = try testParse(&.{ "/sl15", "foo.rc" });
1750        defer options.deinit();
1751
1752        try std.testing.expectEqual(@as(u15, 1228), options.max_string_literal_codepoints);
1753    }
1754}
1755
1756test "parse: unsupported MUI-related options" {
1757    try testParseError(&.{ "/q", "blah", "/g1", "-G2", "blah", "/fm", "blah", "/g", "blah", "foo.rc" },
1758        \\<cli>: error: the /q option is unsupported
1759        \\ ... /q ...
1760        \\     ~^
1761        \\<cli>: error: the /g1 option is unsupported
1762        \\ ... /g1 ...
1763        \\     ~^~
1764        \\<cli>: error: the -G2 option is unsupported
1765        \\ ... -G2 ...
1766        \\     ~^~
1767        \\<cli>: error: the /fm option is unsupported
1768        \\ ... /fm ...
1769        \\     ~^~
1770        \\<cli>: error: the /g option is unsupported
1771        \\ ... /g ...
1772        \\     ~^
1773        \\
1774    );
1775}
1776
1777test "parse: unsupported LCX/LCE-related options" {
1778    try testParseError(&.{ "/t", "/tp:", "/tp:blah", "/tm", "/tc", "/tw", "-TEti", "/ta", "/tn", "blah", "foo.rc" },
1779        \\<cli>: error: the /t option is unsupported
1780        \\ ... /t ...
1781        \\     ~^
1782        \\<cli>: error: missing value for /tp: option
1783        \\ ... /tp:  ...
1784        \\     ~~~~^
1785        \\<cli>: error: the /tp: option is unsupported
1786        \\ ... /tp: ...
1787        \\     ~^~~
1788        \\<cli>: error: the /tp: option is unsupported
1789        \\ ... /tp:blah ...
1790        \\     ~^~~~~~~
1791        \\<cli>: error: the /tm option is unsupported
1792        \\ ... /tm ...
1793        \\     ~^~
1794        \\<cli>: error: the /tc option is unsupported
1795        \\ ... /tc ...
1796        \\     ~^~
1797        \\<cli>: error: the /tw option is unsupported
1798        \\ ... /tw ...
1799        \\     ~^~
1800        \\<cli>: error: the -TE option is unsupported
1801        \\ ... -TEti ...
1802        \\     ~^~
1803        \\<cli>: error: the -ti option is unsupported
1804        \\ ... -TEti ...
1805        \\     ~  ^~
1806        \\<cli>: error: the /ta option is unsupported
1807        \\ ... /ta ...
1808        \\     ~^~
1809        \\<cli>: error: the /tn option is unsupported
1810        \\ ... /tn ...
1811        \\     ~^~
1812        \\
1813    );
1814}
1815
1816test "parse: output filename specified twice" {
1817    try testParseError(&.{ "/fo", "foo.res", "foo.rc", "foo.res" },
1818        \\<cli>: error: output filename already specified
1819        \\ ... foo.res
1820        \\     ^~~~~~~
1821        \\<cli>: note: output filename previously specified here
1822        \\ ... /fo foo.res ...
1823        \\     ~~~~^~~~~~~
1824        \\
1825    );
1826}
1827
1828test "parse: input and output formats" {
1829    {
1830        try testParseError(&.{ "/:output-format", "rcpp", "foo.res" },
1831            \\<cli>: error: input format 'res' cannot be converted to output format 'rcpp'
1832            \\
1833            \\<cli>: note: the input format was inferred from the input filename
1834            \\ ... foo.res
1835            \\     ^~~~~~~
1836            \\<cli>: note: the output format was specified here
1837            \\ ... /:output-format rcpp ...
1838            \\     ~~~~~~~~~~~~~~~~^~~~
1839            \\
1840        );
1841    }
1842    {
1843        try testParseError(&.{ "foo.res", "foo.rcpp" },
1844            \\<cli>: error: input format 'res' cannot be converted to output format 'rcpp'
1845            \\
1846            \\<cli>: note: the input format was inferred from the input filename
1847            \\ ... foo.res ...
1848            \\     ^~~~~~~
1849            \\<cli>: note: the output format was inferred from the output filename
1850            \\ ... foo.rcpp
1851            \\     ^~~~~~~~
1852            \\
1853        );
1854    }
1855    {
1856        try testParseError(&.{ "/:input-format", "res", "foo" },
1857            \\<cli>: error: input format 'res' cannot be converted to output format 'res'
1858            \\
1859            \\<cli>: note: the input format was specified here
1860            \\ ... /:input-format res ...
1861            \\     ~~~~~~~~~~~~~~~^~~
1862            \\<cli>: note: the output format was unable to be inferred from the input filename, so the default was used
1863            \\ ... foo
1864            \\     ^~~
1865            \\
1866        );
1867    }
1868    {
1869        try testParseError(&.{ "/p", "/:input-format", "res", "foo" },
1870            \\<cli>: error: input format 'res' cannot be converted to output format 'res'
1871            \\
1872            \\<cli>: error: the /p option cannot be used with output format 'res'
1873            \\ ... /p ...
1874            \\     ^~
1875            \\<cli>: note: the input format was specified here
1876            \\ ... /:input-format res ...
1877            \\     ~~~~~~~~~~~~~~~^~~
1878            \\<cli>: note: the output format was unable to be inferred from the input filename, so the default was used
1879            \\ ... foo
1880            \\     ^~~
1881            \\
1882        );
1883    }
1884    {
1885        try testParseError(&.{ "/:output-format", "coff", "/p", "foo.rc" },
1886            \\<cli>: error: the /p option cannot be used with output format 'coff'
1887            \\ ... /p ...
1888            \\     ^~
1889            \\<cli>: note: the output format was specified here
1890            \\ ... /:output-format coff ...
1891            \\     ~~~~~~~~~~~~~~~~^~~~
1892            \\
1893        );
1894    }
1895    {
1896        try testParseError(&.{ "/fo", "foo.res", "/p", "foo.rc" },
1897            \\<cli>: error: the /p option cannot be used with output format 'res'
1898            \\ ... /p ...
1899            \\     ^~
1900            \\<cli>: note: the output format was inferred from the output filename
1901            \\ ... /fo foo.res ...
1902            \\     ~~~~^~~~~~~
1903            \\
1904        );
1905    }
1906    {
1907        try testParseError(&.{ "/p", "foo.rc", "foo.o" },
1908            \\<cli>: error: the /p option cannot be used with output format 'coff'
1909            \\ ... /p ...
1910            \\     ^~
1911            \\<cli>: note: the output format was inferred from the output filename
1912            \\ ... foo.o
1913            \\     ^~~~~
1914            \\
1915        );
1916    }
1917    {
1918        var options = try testParse(&.{"foo.rc"});
1919        defer options.deinit();
1920
1921        try std.testing.expectEqual(.rc, options.input_format);
1922        try std.testing.expectEqual(.res, options.output_format);
1923    }
1924    {
1925        var options = try testParse(&.{"foo.rcpp"});
1926        defer options.deinit();
1927
1928        try std.testing.expectEqual(.no, options.preprocess);
1929        try std.testing.expectEqual(.rcpp, options.input_format);
1930        try std.testing.expectEqual(.res, options.output_format);
1931    }
1932    {
1933        var options = try testParse(&.{ "foo.rc", "foo.rcpp" });
1934        defer options.deinit();
1935
1936        try std.testing.expectEqual(.only, options.preprocess);
1937        try std.testing.expectEqual(.rc, options.input_format);
1938        try std.testing.expectEqual(.rcpp, options.output_format);
1939    }
1940    {
1941        var options = try testParse(&.{ "foo.rc", "foo.obj" });
1942        defer options.deinit();
1943
1944        try std.testing.expectEqual(.rc, options.input_format);
1945        try std.testing.expectEqual(.coff, options.output_format);
1946    }
1947    {
1948        var options = try testParse(&.{ "/fo", "foo.o", "foo.rc" });
1949        defer options.deinit();
1950
1951        try std.testing.expectEqual(.rc, options.input_format);
1952        try std.testing.expectEqual(.coff, options.output_format);
1953    }
1954    {
1955        var options = try testParse(&.{"foo.res"});
1956        defer options.deinit();
1957
1958        try std.testing.expectEqual(.res, options.input_format);
1959        try std.testing.expectEqual(.coff, options.output_format);
1960    }
1961    {
1962        var options = try testParseWarning(&.{ "/:depfile", "foo.json", "foo.rc", "foo.rcpp" },
1963            \\<cli>: warning: the /:depfile option was ignored because the output format is 'rcpp'
1964            \\ ... /:depfile foo.json ...
1965            \\     ~~~~~~~~~~^~~~~~~~
1966            \\<cli>: note: the output format was inferred from the output filename
1967            \\ ... foo.rcpp
1968            \\     ^~~~~~~~
1969            \\
1970        );
1971        defer options.deinit();
1972
1973        try std.testing.expectEqual(.rc, options.input_format);
1974        try std.testing.expectEqual(.rcpp, options.output_format);
1975    }
1976    {
1977        var options = try testParseWarning(&.{ "/:depfile", "foo.json", "foo.res", "foo.o" },
1978            \\<cli>: warning: the /:depfile option was ignored because the input format is 'res'
1979            \\ ... /:depfile foo.json ...
1980            \\     ~~~~~~~~~~^~~~~~~~
1981            \\<cli>: note: the input format was inferred from the input filename
1982            \\ ... foo.res ...
1983            \\     ^~~~~~~
1984            \\
1985        );
1986        defer options.deinit();
1987
1988        try std.testing.expectEqual(.res, options.input_format);
1989        try std.testing.expectEqual(.coff, options.output_format);
1990    }
1991}
1992
1993test "maybeAppendRC" {
1994    var tmp = std.testing.tmpDir(.{});
1995    defer tmp.cleanup();
1996
1997    var options = try testParse(&.{"foo"});
1998    defer options.deinit();
1999    try std.testing.expectEqualStrings("foo", options.input_source.filename);
2000
2001    // Create the file so that it's found. In this scenario, .rc should not get
2002    // appended.
2003    var file = try tmp.dir.createFile("foo", .{});
2004    file.close();
2005    try options.maybeAppendRC(tmp.dir);
2006    try std.testing.expectEqualStrings("foo", options.input_source.filename);
2007
2008    // Now delete the file and try again. But this time change the input format
2009    // to non-rc.
2010    try tmp.dir.deleteFile("foo");
2011    options.input_format = .res;
2012    try options.maybeAppendRC(tmp.dir);
2013    try std.testing.expectEqualStrings("foo", options.input_source.filename);
2014
2015    // Finally, reset the input format to rc. Since the verbatim name is no longer found
2016    // and the input filename does not have an extension, .rc should get appended.
2017    options.input_format = .rc;
2018    try options.maybeAppendRC(tmp.dir);
2019    try std.testing.expectEqualStrings("foo.rc", options.input_source.filename);
2020}