Commit 2769215b90

Ryan Liptak <squeek502@hotmail.com>
2023-10-05 12:30:31
Add `zig rc` subcommand, a drop-in replacement for rc.exe
Uses resinator under-the-hood (see https://github.com/ziglang/zig/pull/17069) Closes #9564
1 parent 375bb5f
src/resinator/cli.zig
@@ -8,8 +8,8 @@ const lex = @import("lex.zig");
 /// This is what /SL 100 will set the maximum string literal length to
 pub const max_string_literal_length_100_percent = 8192;
 
-pub const usage_string =
-    \\Usage: resinator [options] [--] <INPUT> [<OUTPUT>]
+pub const usage_string_after_command_name =
+    \\ [options] [--] <INPUT> [<OUTPUT>]
     \\
     \\The sequence -- can be used to signify when to stop parsing options.
     \\This is necessary when the input path begins with a forward slash.
@@ -57,6 +57,12 @@ pub const usage_string =
     \\
 ;
 
+pub fn writeUsage(writer: anytype, command_name: []const u8) !void {
+    try writer.writeAll("Usage: ");
+    try writer.writeAll(command_name);
+    try writer.writeAll(usage_string_after_command_name);
+}
+
 pub const Diagnostics = struct {
     errors: std.ArrayListUnmanaged(ErrorDetails) = .{},
     allocator: Allocator,
src/resinator/preprocess.zig
@@ -0,0 +1,94 @@
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const cli = @import("cli.zig");
+
+pub const IncludeArgs = struct {
+    clang_target: ?[]const u8 = null,
+    system_include_paths: []const []const u8,
+    /// Should be set to `true` when -target has the GNU abi
+    /// (either because `clang_target` has `-gnu` or `-target`
+    /// is appended via other means and it has `-gnu`)
+    needs_gnu_workaround: bool = false,
+    nostdinc: bool = false,
+
+    pub const IncludeAbi = enum {
+        msvc,
+        gnu,
+    };
+};
+
+/// `arena` is used for temporary -D argument strings and the INCLUDE environment variable.
+/// The arena should be kept alive at least as long as `argv`.
+pub fn appendClangArgs(arena: Allocator, argv: *std.ArrayList([]const u8), options: cli.Options, include_args: IncludeArgs) !void {
+    try argv.appendSlice(&[_][]const u8{
+        "-E", // preprocessor only
+        "--comments",
+        "-fuse-line-directives", // #line <num> instead of # <num>
+        // TODO: could use --trace-includes to give info about what's included from where
+        "-xc", // output c
+        // TODO: Turn this off, check the warnings, and convert the spaces back to NUL
+        "-Werror=null-character", // error on null characters instead of converting them to spaces
+        // TODO: could remove -Werror=null-character and instead parse warnings looking for 'warning: null character ignored'
+        //       since the only real problem is when clang doesn't preserve null characters
+        //"-Werror=invalid-pp-token", // will error on unfinished string literals
+        // TODO: could use -Werror instead
+        "-fms-compatibility", // Allow things like "header.h" to be resolved relative to the 'root' .rc file, among other things
+        // https://learn.microsoft.com/en-us/windows/win32/menurc/predefined-macros
+        "-DRC_INVOKED",
+    });
+    for (options.extra_include_paths.items) |extra_include_path| {
+        try argv.append("-I");
+        try argv.append(extra_include_path);
+    }
+
+    if (include_args.nostdinc) {
+        try argv.append("-nostdinc");
+    }
+    for (include_args.system_include_paths) |include_path| {
+        try argv.append("-isystem");
+        try argv.append(include_path);
+    }
+    if (include_args.clang_target) |target| {
+        try argv.append("-target");
+        try argv.append(target);
+    }
+    // Using -fms-compatibility and targeting the GNU abi interact in a strange way:
+    // - Targeting the GNU abi stops _MSC_VER from being defined
+    // - Passing -fms-compatibility stops __GNUC__ from being defined
+    // Neither being defined is a problem for things like MinGW's vadefs.h,
+    // which will fail during preprocessing if neither are defined.
+    // So, when targeting the GNU abi, we need to force __GNUC__ to be defined.
+    //
+    // TODO: This is a workaround that should be removed if possible.
+    if (include_args.needs_gnu_workaround) {
+        // This is the same default gnuc version that Clang uses:
+        // https://github.com/llvm/llvm-project/blob/4b5366c9512aa273a5272af1d833961e1ed156e7/clang/lib/Driver/ToolChains/Clang.cpp#L6738
+        try argv.append("-fgnuc-version=4.2.1");
+    }
+
+    if (!options.ignore_include_env_var) {
+        const INCLUDE = std.process.getEnvVarOwned(arena, "INCLUDE") catch "";
+
+        // TODO: Should this be platform-specific? How does windres/llvm-rc handle this (if at all)?
+        var it = std.mem.tokenize(u8, INCLUDE, ";");
+        while (it.next()) |include_path| {
+            try argv.append("-isystem");
+            try argv.append(include_path);
+        }
+    }
+
+    var symbol_it = options.symbols.iterator();
+    while (symbol_it.next()) |entry| {
+        switch (entry.value_ptr.*) {
+            .define => |value| {
+                try argv.append("-D");
+                const define_arg = try std.fmt.allocPrint(arena, "{s}={s}", .{ entry.key_ptr.*, value });
+                try argv.append(define_arg);
+            },
+            .undefine => {
+                try argv.append("-U");
+                try argv.append(entry.key_ptr.*);
+            },
+        }
+    }
+}
src/resinator/utils.zig
@@ -81,3 +81,32 @@ pub fn isNonAsciiDigit(c: u21) bool {
         else => false,
     };
 }
+
+/// Used for generic colored errors/warnings/notes, more context-specific error messages
+/// are handled elsewhere.
+pub fn renderErrorMessage(writer: anytype, config: std.io.tty.Config, msg_type: enum { err, warning, note }, comptime format: []const u8, args: anytype) !void {
+    switch (msg_type) {
+        .err => {
+            try config.setColor(writer, .bold);
+            try config.setColor(writer, .red);
+            try writer.writeAll("error: ");
+        },
+        .warning => {
+            try config.setColor(writer, .bold);
+            try config.setColor(writer, .yellow);
+            try writer.writeAll("warning: ");
+        },
+        .note => {
+            try config.setColor(writer, .reset);
+            try config.setColor(writer, .cyan);
+            try writer.writeAll("note: ");
+        },
+    }
+    try config.setColor(writer, .reset);
+    if (msg_type == .err) {
+        try config.setColor(writer, .bold);
+    }
+    try writer.print(format, args);
+    try writer.writeByte('\n');
+    try config.setColor(writer, .reset);
+}
src/Compilation.zig
@@ -4607,63 +4607,18 @@ fn updateWin32Resource(comp: *Compilation, win32_resource: *Win32Resource, win32
 
         var argv = std.ArrayList([]const u8).init(comp.gpa);
         defer argv.deinit();
-        var temp_strings = std.ArrayList([]const u8).init(comp.gpa);
-        defer {
-            for (temp_strings.items) |temp_string| {
-                comp.gpa.free(temp_string);
-            }
-            temp_strings.deinit();
-        }
 
         // TODO: support options.preprocess == .no and .only
         //       alternatively, error if those options are used
-        try argv.appendSlice(&[_][]const u8{
-            self_exe_path,
-            "clang",
-            "-E", // preprocessor only
-            "--comments",
-            "-fuse-line-directives", // #line <num> instead of # <num>
-            "-xc", // output c
-            "-Werror=null-character", // error on null characters instead of converting them to spaces
-            "-fms-compatibility", // Allow things like "header.h" to be resolved relative to the 'root' .rc file, among other things
-            "-DRC_INVOKED", // https://learn.microsoft.com/en-us/windows/win32/menurc/predefined-macros
+        try argv.appendSlice(&[_][]const u8{ self_exe_path, "clang" });
+
+        try resinator.preprocess.appendClangArgs(arena, &argv, options, .{
+            .clang_target = null, // handled by addCCArgs
+            .system_include_paths = &.{}, // handled by addCCArgs
+            .needs_gnu_workaround = comp.getTarget().isGnu(),
+            .nostdinc = false, // handled by addCCArgs
         });
-        // Using -fms-compatibility and targeting the gnu abi interact in a strange way:
-        // - Targeting the GNU abi stops _MSC_VER from being defined
-        // - Passing -fms-compatibility stops __GNUC__ from being defined
-        // Neither being defined is a problem for things like things like MinGW's
-        // vadefs.h, which will fail during preprocessing if neither are defined.
-        // So, when targeting the GNU abi, we need to force __GNUC__ to be defined.
-        //
-        // TODO: This is a workaround that should be removed if possible.
-        if (comp.getTarget().isGnu()) {
-            // This is the same default gnuc version that Clang uses:
-            // https://github.com/llvm/llvm-project/blob/4b5366c9512aa273a5272af1d833961e1ed156e7/clang/lib/Driver/ToolChains/Clang.cpp#L6738
-            try argv.append("-fgnuc-version=4.2.1");
-        }
-        for (options.extra_include_paths.items) |extra_include_path| {
-            try argv.append("--include-directory");
-            try argv.append(extra_include_path);
-        }
-        var symbol_it = options.symbols.iterator();
-        while (symbol_it.next()) |entry| {
-            switch (entry.value_ptr.*) {
-                .define => |value| {
-                    try argv.append("-D");
-                    const define_arg = arg: {
-                        const arg = try std.fmt.allocPrint(comp.gpa, "{s}={s}", .{ entry.key_ptr.*, value });
-                        errdefer comp.gpa.free(arg);
-                        try temp_strings.append(arg);
-                        break :arg arg;
-                    };
-                    try argv.append(define_arg);
-                },
-                .undefine => {
-                    try argv.append("-U");
-                    try argv.append(entry.key_ptr.*);
-                },
-            }
-        }
+
         try argv.append(win32_resource.src.src_path);
         try argv.appendSlice(&[_][]const u8{
             "-o",
src/main.zig
@@ -104,6 +104,7 @@ const normal_usage =
     \\  lib              Use Zig as a drop-in lib.exe
     \\  ranlib           Use Zig as a drop-in ranlib
     \\  objcopy          Use Zig as a drop-in objcopy
+    \\  rc               Use Zig as a drop-in rc.exe
     \\
     \\  env              Print lib path, std path, cache directory, and version
     \\  help             Print this help and exit
@@ -300,6 +301,8 @@ pub fn mainArgs(gpa: Allocator, arena: Allocator, args: []const []const u8) !voi
         return buildOutputType(gpa, arena, args, .cpp);
     } else if (mem.eql(u8, cmd, "translate-c")) {
         return buildOutputType(gpa, arena, args, .translate_c);
+    } else if (mem.eql(u8, cmd, "rc")) {
+        return cmdRc(gpa, arena, args[1..]);
     } else if (mem.eql(u8, cmd, "fmt")) {
         return cmdFmt(gpa, arena, cmd_args);
     } else if (mem.eql(u8, cmd, "objcopy")) {
@@ -4372,6 +4375,270 @@ fn cmdTranslateC(comp: *Compilation, arena: Allocator, fancy_output: ?*Compilati
     }
 }
 
+fn cmdRc(gpa: Allocator, arena: Allocator, args: []const []const u8) !void {
+    const resinator = @import("resinator.zig");
+
+    const stderr = std.io.getStdErr();
+    const stderr_config = std.io.tty.detectConfig(stderr);
+
+    var options = options: {
+        var cli_diagnostics = resinator.cli.Diagnostics.init(gpa);
+        defer cli_diagnostics.deinit();
+        var options = resinator.cli.parse(gpa, args, &cli_diagnostics) catch |err| switch (err) {
+            error.ParseError => {
+                cli_diagnostics.renderToStdErr(args, stderr_config);
+                process.exit(1);
+            },
+            else => |e| return e,
+        };
+        try options.maybeAppendRC(std.fs.cwd());
+
+        // print any warnings/notes
+        cli_diagnostics.renderToStdErr(args, stderr_config);
+        // If there was something printed, then add an extra newline separator
+        // so that there is a clear separation between the cli diagnostics and whatever
+        // gets printed after
+        if (cli_diagnostics.errors.items.len > 0) {
+            std.debug.print("\n", .{});
+        }
+        break :options options;
+    };
+    defer options.deinit();
+
+    if (options.print_help_and_exit) {
+        try resinator.cli.writeUsage(stderr.writer(), "zig rc");
+        return;
+    }
+
+    const stdout_writer = std.io.getStdOut().writer();
+    if (options.verbose) {
+        try options.dumpVerbose(stdout_writer);
+        try stdout_writer.writeByte('\n');
+    }
+
+    var full_input = full_input: {
+        if (options.preprocess != .no) {
+            if (!build_options.have_llvm) {
+                fatal("clang not available: compiler built without LLVM extensions", .{});
+            }
+
+            var argv = std.ArrayList([]const u8).init(gpa);
+            defer argv.deinit();
+
+            const self_exe_path = try introspect.findZigExePath(arena);
+            var zig_lib_directory = introspect.findZigLibDirFromSelfExe(arena, self_exe_path) catch |err| {
+                try resinator.utils.renderErrorMessage(stderr.writer(), stderr_config, .err, "unable to find zig installation directory: {s}", .{@errorName(err)});
+                process.exit(1);
+            };
+            defer zig_lib_directory.handle.close();
+
+            const include_args = detectRcIncludeDirs(arena, zig_lib_directory.path.?, options.auto_includes) catch |err| {
+                try resinator.utils.renderErrorMessage(stderr.writer(), stderr_config, .err, "unable to detect system include directories: {s}", .{@errorName(err)});
+                process.exit(1);
+            };
+
+            try argv.appendSlice(&[_][]const u8{ self_exe_path, "clang" });
+
+            const clang_target = clang_target: {
+                if (include_args.target_abi) |abi| {
+                    break :clang_target try std.fmt.allocPrint(arena, "x86_64-unknown-windows-{s}", .{abi});
+                }
+                break :clang_target "x86_64-unknown-windows";
+            };
+            try resinator.preprocess.appendClangArgs(arena, &argv, options, .{
+                .clang_target = clang_target,
+                .system_include_paths = include_args.include_paths,
+                .needs_gnu_workaround = if (include_args.target_abi) |abi| std.mem.eql(u8, abi, "gnu") else false,
+                .nostdinc = true,
+            });
+
+            try argv.append(options.input_filename);
+
+            if (options.verbose) {
+                try stdout_writer.writeAll("Preprocessor: zig clang\n");
+                for (argv.items[0 .. argv.items.len - 1]) |arg| {
+                    try stdout_writer.print("{s} ", .{arg});
+                }
+                try stdout_writer.print("{s}\n\n", .{argv.items[argv.items.len - 1]});
+            }
+
+            if (std.process.can_spawn) {
+                var result = std.ChildProcess.exec(.{
+                    .allocator = gpa,
+                    .argv = argv.items,
+                    .max_output_bytes = std.math.maxInt(u32),
+                }) catch |err| {
+                    try resinator.utils.renderErrorMessage(stderr.writer(), stderr_config, .err, "unable to spawn preprocessor child process: {s}", .{@errorName(err)});
+                    process.exit(1);
+                };
+                errdefer gpa.free(result.stdout);
+                defer gpa.free(result.stderr);
+
+                switch (result.term) {
+                    .Exited => |code| {
+                        if (code != 0) {
+                            try resinator.utils.renderErrorMessage(stderr.writer(), stderr_config, .err, "the preprocessor failed with exit code {}:", .{code});
+                            try stderr.writeAll(result.stderr);
+                            try stderr.writeAll("\n");
+                            process.exit(1);
+                        }
+                    },
+                    .Signal, .Stopped, .Unknown => {
+                        try resinator.utils.renderErrorMessage(stderr.writer(), stderr_config, .err, "the preprocessor terminated unexpectedly ({s}):", .{@tagName(result.term)});
+                        try stderr.writeAll(result.stderr);
+                        try stderr.writeAll("\n");
+                        process.exit(1);
+                    },
+                }
+
+                break :full_input result.stdout;
+            } else {
+                // need to use an intermediate file
+                const rand_int = std.crypto.random.int(u64);
+                const preprocessed_path = try std.fmt.allocPrint(gpa, "resinator{x}.rcpp", .{rand_int});
+                defer gpa.free(preprocessed_path);
+                defer std.fs.cwd().deleteFile(preprocessed_path) catch {};
+
+                try argv.appendSlice(&.{ "-o", preprocessed_path });
+                const exit_code = try clangMain(arena, argv.items);
+                if (exit_code != 0) {
+                    try resinator.utils.renderErrorMessage(stderr.writer(), stderr_config, .err, "the preprocessor failed with exit code {}:", .{exit_code});
+                    process.exit(1);
+                }
+                break :full_input std.fs.cwd().readFileAlloc(gpa, preprocessed_path, std.math.maxInt(usize)) catch |err| {
+                    try resinator.utils.renderErrorMessage(stderr.writer(), stderr_config, .err, "unable to read preprocessed file path '{s}': {s}", .{ preprocessed_path, @errorName(err) });
+                    process.exit(1);
+                };
+            }
+        } else {
+            break :full_input std.fs.cwd().readFileAlloc(gpa, options.input_filename, std.math.maxInt(usize)) catch |err| {
+                try resinator.utils.renderErrorMessage(stderr.writer(), stderr_config, .err, "unable to read input file path '{s}': {s}", .{ options.input_filename, @errorName(err) });
+                process.exit(1);
+            };
+        }
+    };
+    defer gpa.free(full_input);
+
+    if (options.preprocess == .only) {
+        std.fs.cwd().writeFile(options.output_filename, full_input) catch |err| {
+            try resinator.utils.renderErrorMessage(stderr.writer(), stderr_config, .err, "unable to write output file '{s}': {s}", .{ options.output_filename, @errorName(err) });
+            process.exit(1);
+        };
+        return cleanExit();
+    }
+
+    var mapping_results = try resinator.source_mapping.parseAndRemoveLineCommands(gpa, full_input, full_input, .{ .initial_filename = options.input_filename });
+    defer mapping_results.mappings.deinit(gpa);
+
+    var final_input = resinator.comments.removeComments(mapping_results.result, mapping_results.result, &mapping_results.mappings);
+
+    var output_file = std.fs.cwd().createFile(options.output_filename, .{}) catch |err| {
+        try resinator.utils.renderErrorMessage(stderr.writer(), stderr_config, .err, "unable to create output file '{s}': {s}", .{ options.output_filename, @errorName(err) });
+        process.exit(1);
+    };
+    var output_file_closed = false;
+    defer if (!output_file_closed) output_file.close();
+
+    var diagnostics = resinator.errors.Diagnostics.init(gpa);
+    defer diagnostics.deinit();
+
+    var output_buffered_stream = std.io.bufferedWriter(output_file.writer());
+
+    resinator.compile.compile(gpa, final_input, output_buffered_stream.writer(), .{
+        .cwd = std.fs.cwd(),
+        .diagnostics = &diagnostics,
+        .source_mappings = &mapping_results.mappings,
+        .dependencies_list = null,
+        .ignore_include_env_var = options.ignore_include_env_var,
+        .extra_include_paths = options.extra_include_paths.items,
+        .default_language_id = options.default_language_id,
+        .default_code_page = options.default_code_page orelse .windows1252,
+        .verbose = options.verbose,
+        .null_terminate_string_table_strings = options.null_terminate_string_table_strings,
+        .max_string_literal_codepoints = options.max_string_literal_codepoints,
+        .silent_duplicate_control_ids = options.silent_duplicate_control_ids,
+        .warn_instead_of_error_on_invalid_code_page = options.warn_instead_of_error_on_invalid_code_page,
+    }) catch |err| switch (err) {
+        error.ParseError, error.CompileError => {
+            diagnostics.renderToStdErr(std.fs.cwd(), final_input, stderr_config, mapping_results.mappings);
+            // Delete the output file on error
+            output_file.close();
+            output_file_closed = true;
+            // Failing to delete is not really a big deal, so swallow any errors
+            std.fs.cwd().deleteFile(options.output_filename) catch {};
+            process.exit(1);
+        },
+        else => |e| return e,
+    };
+
+    try output_buffered_stream.flush();
+
+    // print any warnings/notes
+    diagnostics.renderToStdErr(std.fs.cwd(), final_input, stderr_config, mapping_results.mappings);
+
+    return cleanExit();
+}
+
+const RcIncludeArgs = struct {
+    include_paths: []const []const u8 = &.{},
+    target_abi: ?[]const u8 = null,
+};
+
+fn detectRcIncludeDirs(arena: Allocator, zig_lib_dir: []const u8, auto_includes: @import("resinator.zig").cli.Options.AutoIncludes) !RcIncludeArgs {
+    if (auto_includes == .none) return .{};
+    var cur_includes = auto_includes;
+    if (builtin.target.os.tag != .windows) {
+        switch (cur_includes) {
+            // MSVC can't be found when the host isn't Windows, so short-circuit.
+            .msvc => return error.WindowsSdkNotFound,
+            // Skip straight to gnu since we won't be able to detect MSVC on non-Windows hosts.
+            .any => cur_includes = .gnu,
+            .gnu => {},
+            .none => unreachable,
+        }
+    }
+    while (true) {
+        switch (cur_includes) {
+            .any, .msvc => {
+                const cross_target = std.zig.CrossTarget.parse(.{ .arch_os_abi = "native-windows-msvc" }) catch unreachable;
+                const target = cross_target.toTarget();
+                const is_native_abi = cross_target.isNativeAbi();
+                const detected_libc = Compilation.detectLibCIncludeDirs(arena, zig_lib_dir, target, is_native_abi, true, null) catch |err| {
+                    if (cur_includes == .any) {
+                        // fall back to mingw
+                        cur_includes = .gnu;
+                        continue;
+                    }
+                    return err;
+                };
+                if (detected_libc.libc_include_dir_list.len == 0) {
+                    if (cur_includes == .any) {
+                        // fall back to mingw
+                        cur_includes = .gnu;
+                        continue;
+                    }
+                    return error.WindowsSdkNotFound;
+                }
+                return .{
+                    .include_paths = detected_libc.libc_include_dir_list,
+                    .target_abi = "msvc",
+                };
+            },
+            .gnu => {
+                const cross_target = std.zig.CrossTarget.parse(.{ .arch_os_abi = "native-windows-gnu" }) catch unreachable;
+                const target = cross_target.toTarget();
+                const is_native_abi = cross_target.isNativeAbi();
+                const detected_libc = try Compilation.detectLibCIncludeDirs(arena, zig_lib_dir, target, is_native_abi, true, null);
+                return .{
+                    .include_paths = detected_libc.libc_include_dir_list,
+                    .target_abi = "gnu",
+                };
+            },
+            .none => unreachable,
+        }
+    }
+}
+
 pub const usage_libc =
     \\Usage: zig libc
     \\
src/resinator.zig
@@ -17,6 +17,7 @@ pub const lang = @import("resinator/lang.zig");
 pub const lex = @import("resinator/lex.zig");
 pub const literals = @import("resinator/literals.zig");
 pub const parse = @import("resinator/parse.zig");
+pub const preprocess = @import("resinator/preprocess.zig");
 pub const rc = @import("resinator/rc.zig");
 pub const res = @import("resinator/res.zig");
 pub const source_mapping = @import("resinator/source_mapping.zig");