diff --git a/CHANGELOG.md b/CHANGELOG.md index 40108791e1dda71a087b2605b09cf94cdebcfafe..fe03f9e3005bc6af1baf5e863e57e42dcd27adf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,16 @@ ## 0.1.6 - breaking: rename `@js` to `@extern`, considering future backends +- breaking: rename `noreturn` to `never` - re-write most of the semantic analysis pass (#79) - feat: implement function expressions - feat: implement simple `comptime` evaluation - feat: change all semantic analysis panics into handled errors - fix: no longer analyzes contents past a block end -- feat: add `--run` to execute the file in QuickJS instead of outputting source +- feat: introduce cli subcommands +- feat: add `run` subcommand to execute the file in QuickJS instead of outputting source - feat: add array index syntax via `x[1]` and `x.1` - feat: add `len` property to arrays -- feat: rename `noreturn` to `never` - feat: allow instantiating `@extern` at compile time - feat: implement tuples - feat: implement multiline string literals diff --git a/build.zig b/build.zig index 85ee1c9947e81bafd448cefb65153cd242fd11f4..de25ba27b57aa2359c760f449487f958c7a52965 100644 --- a/build.zig +++ b/build.zig @@ -400,6 +400,7 @@ pub fn build(b: *Build) !void { b.installArtifact(main.cli); inline for (.{ + .{ main.cli, "run" }, .{ minimal.compiler_cli, "compiler" }, .{ minimal.format_cli, "format" }, .{ minimal.loader_cli, "loader" }, diff --git a/src/Cli.zig b/src/Cli.zig index 39ee68fcf7bf3361b25ee74cb1c27923ad37e1df..6bd42b813f5ed6a81c5051d3d44487241a4a22bf 100644 --- a/src/Cli.zig +++ b/src/Cli.zig @@ -3,11 +3,18 @@ //! allocator, however it expects incoming arguments to be UTF-8 formatted which //! on Windows will require an allocator. //! -//! See `fn main` for a basic usage example. -options: []const Option = &.{}, -positionals: Positionals = .none, +//! See `fn main` for a basic usage example. Parsing subcommands is done in +//! `main.zig`, utilizing a different `Cli` for each option set. +options: []const Option, +positionals: Positionals, help: HelpConfig = .{}, +pub const empty: Cli = .{ + .options = &.{}, + .positionals = .none, + .help = .{ .option = false }, +}; + pub const Option = struct { /// Should be formatted as the flag name: '--dump-ir' name: []const u8, @@ -262,14 +269,15 @@ pub fn parse( stderr.get(), stderr.tty, true, - true, ) catch |e| switch (e) { - error.InvalidArguments, - error.DisplayedHelpMenu, - => { + error.InvalidArguments => { stderr.buffered_writer.flush() catch {}; std.process.exit(1); }, + error.DisplayedHelpMenu => { + stderr.buffered_writer.flush() catch {}; + std.process.exit(0); + }, }; } @@ -279,9 +287,9 @@ pub fn parseWithErrorWriter( args: []const []const u8, err: std.io.AnyWriter, tty: std.io.tty.Config, - comptime show_help_on_error: bool, comptime exit_on_error: bool, ) ParseError!p.Parsed() { + _ = exit_on_error; // autofix var out: p.Parsed() = .{}; var had_errors = false; var positionals_counted: usize = 0; @@ -312,7 +320,7 @@ pub fn parseWithErrorWriter( positionals_counted += remain.len; if (positionals_counted > max_positionals) { - had_errors = true; + p.markFirstError(&had_errors, program_name, err, tty); const diff = @min(remain.len, positionals_counted - max_positionals); for (remain[remain.len - diff ..]) |pos| { writeError(err, tty); @@ -333,7 +341,7 @@ pub fn parseWithErrorWriter( break :try_this_def; if (kind != .boolean and is_blank) { - had_errors = true; + p.markFirstError(&had_errors, program_name, err, tty); writeError(err, tty); err.print(prefix ++ " requires a value\n", .{}) catch {}; continue :outer; @@ -348,7 +356,7 @@ pub fn parseWithErrorWriter( } else if (mem.eql(u8, value, "true")) { dest.* = true; } else { - had_errors = true; + p.markFirstError(&had_errors, program_name, err, tty); writeError(err, tty); err.print(prefix ++ " only accepts true or false, got '{s}'\n", .{ arg[prefix.len + 1 ..], @@ -358,14 +366,14 @@ pub fn parseWithErrorWriter( .string => if (std.unicode.utf8ValidateSlice(value)) { dest.* = value; } else { - had_errors = true; + p.markFirstError(&had_errors, program_name, err, tty); writeError(err, tty); err.print(prefix ++ " requires valid UTF-8 input\n", .{}) catch {}; }, .@"enum" => |E| { dest.* = std.meta.stringToEnum(E, value) orelse { - had_errors = true; + p.markFirstError(&had_errors, program_name, err, tty); const values_list = comptime brk: { var str: []const u8 = ""; for (std.enums.values(E)) |val| { @@ -390,14 +398,14 @@ pub fn parseWithErrorWriter( if (mem.startsWith(u8, arg[2..], "help") and (arg.len == "--help".len or arg["--help".len] == '=')) { show_help = true; if (arg.len > "--help".len) { - had_errors = true; + p.markFirstError(&had_errors, program_name, err, tty); err.print("--help does not take a value\n", .{}) catch {}; } continue :outer; } } - had_errors = true; + p.markFirstError(&had_errors, program_name, err, tty); writeError(err, tty); err.print("unrecognized argument '{s}'\n", .{arg}) catch {}; } else if (arg.len > 1 and arg[0] == '-') { @@ -421,7 +429,7 @@ pub fn parseWithErrorWriter( if (positionals_counted == 0) first_positional = i; positionals_counted += 1; if (positionals_counted > max_positionals) { - had_errors = true; + p.markFirstError(&had_errors, program_name, err, tty); writeError(err, tty); err.print("unrecognized argument '{s}'\n", .{arg}) catch {}; } else if (p.positionals == .bounded) { @@ -435,19 +443,16 @@ pub fn parseWithErrorWriter( writeNote(err, tty); err.writeAll(switch (max_positionals) { 0 => "this program does not take positional arguments\n", - 1 => "this program accepts one positional argument\n", + 1 => "this program only accepts one positional argument\n", else => |n| std.fmt.comptimePrint( - "this program accepts up to {d} positional arguments\n", + "this program only accepts up to {d} positional arguments\n", .{n}, ), }) catch {}; } - if (show_help or show_help_on_error) { + if (show_help) { p.showHelpWithWriter(program_name, err, tty); } - if (exit_on_error) { - return error.DisplayedHelpMenu; - } return error.InvalidArguments; } @@ -476,6 +481,18 @@ pub fn parseWithErrorWriter( return out; } +pub fn markFirstError( + comptime p: Cli, + ptr: *bool, + program_name: []const u8, + err: std.io.AnyWriter, + tty: std.io.tty.Config, +) void { + if (ptr.* == true) return; + ptr.* = true; + p.showHelpWithWriter(program_name, err, tty); +} + pub fn showHelp(comptime p: Cli, program_name: []const u8) void { var stderr = StdErrWriter.init(); defer stderr.buffered_writer.flush() catch {}; @@ -502,14 +519,18 @@ pub fn showHelpWithWriter( }; }; - w.print("usage: {s} {s}\n", .{ program_name, usage }) catch return; + tty.setColor(w, .bold) catch return; + w.writeAll("usage") catch return; + tty.setColor(w, .reset) catch return; + w.print(": {s} {s}\n", .{ program_name, usage }) catch return; if (p.options.len > 0 or p.help.option) { w.writeAll("\n") catch return; tty.setColor(w, .bold) catch return; - w.writeAll("options:") catch return; + w.writeAll("options") catch return; tty.setColor(w, .reset) catch return; w.writeAll(comptime brk: { + var out: []const u8 = ":\n\n"; var width = 0; var any_short_options = false; @@ -526,7 +547,6 @@ pub fn showHelpWithWriter( if (any_short_options) width += "-h ".len; const sub = if (any_short_options) 4 else 1; - var out: []const u8 = "\n"; for (all_opts) |opt| { out = out ++ " "; if (any_short_options) { @@ -605,12 +625,14 @@ pub fn fatalWithHelp(p: Cli, program_name: []const u8, comptime fmt: []const u8, } pub fn fatal(comptime fmt: []const u8, args: anytype) noreturn { - const stderr = std.io.getStdErr(); - const tty = std.io.tty.detectConfig(stderr); - var bufw = std.io.bufferedWriter(stderr.writer()); - writeError(bufw.writer().any(), tty); - bufw.writer().print(fmt ++ "\n", args); - bufw.flush() catch {}; // ignoring I/O errors + print: { + const stderr = std.io.getStdErr(); + const tty = std.io.tty.detectConfig(stderr); + var bufw = std.io.bufferedWriter(stderr.writer()); + writeError(bufw.writer().any(), tty); + bufw.writer().print(fmt ++ "\n", args) catch break :print; + bufw.flush() catch break :print; + } std.process.exit(1); } diff --git a/src/main.zig b/src/main.zig index b94445681e3b7951c67522860be9ecd2d134e635..450942749cb04907ec12150993845b683c812544 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,31 +1,155 @@ -//! This is the `lang` command line interface. It combines all components -//! of the software development kit into one. Multiple versions of this -//! file are built with different subsets of features, allowing you to, -//! for example, test the `core` module's compilation features without -//! needing to compile the formatter. - -const config = @import("config"); - -const arg_config: Cli = .{ - .options = .{ - .{ .short = 'i', .name = "--dump-ir", .desc = "Dump intermediate representations" }, - .{ .name = "--dump-state", .desc = "Dump compiler state to JSON", .kind = .string }, - } ++ Cli.Option.optional(config.have_embedded_quickjs, &.{ - .{ .name = "--run", .desc = "Execute with QuickJS" }, +//! This is the user-facing command line, combining all features into +//! different subcommands, each with their own option set. + +const default_program_name = "wlang"; // fallback when argv[0] is missing + +const Command = struct { + name: [:0]const u8, + description: []const u8, + cli: Cli, + run: fn (Context) anyerror!void, + enabled: bool = true, + + const ListItem = union(enum) { + separator, + command: Command, + + fn cmd(command: Command) ListItem { + return .{ .command = command }; + } + }; +}; + +const general_compiler_options: []const Cli.Option = &.{ + .{ .short = 'i', .name = "--dump-ir", .desc = "Dump intermediate representations" }, + .{ .name = "--dump-state", .desc = "Dump compiler state to JSON", .kind = .string }, +}; + +const top_level_commands: []const Command.ListItem = &.{ + // Compilation + .cmd(.{ + .name = "build", + .description = "Create an executable from source code", + .cli = .{ + .options = general_compiler_options, + .positionals = .single, + .help = .{ .usage = "[options...] [entry file]" }, + }, + .run = cmdBuild, + }), + .cmd(.{ + .name = "run", + .description = "Create executable and run with QuickJS", + .cli = .{ + .options = general_compiler_options ++ .{ + .{ .name = "--dump-code", .desc = "Dump compiled JavaScript to standard out" }, + }, + .positionals = .single, + .help = .{ .usage = "[options...] [entry file]" }, + }, + .enabled = config.have_embedded_quickjs, + .run = cmdRun, }), - .positionals = .single, + + // Utilities + // .separator, + // .cmd(.{ + // .name = "fmt", + // .description = "Reformat code into canonical form", + // .cli = .{ + // .options = &.{}, + // .positionals = .unlimited, + // .help = .{ .usage = "[options...] [files...]" }, + // }, + // .run = cmdFmt, + // }), + + // Statistics + .separator, + .cmd(.{ + .name = "version", + .description = "Print version number and exit", + .cli = .empty, + .run = undefined, // special cased + }), + .cmd(.{ + .name = "help", + .description = "Print this help and exit", + .cli = .empty, + .run = undefined, // special cased + }), +}; + +const Context = struct { + gpa: Allocator, + cwd: []const u8, + /// argv[0] ++ " " ++ subcommand.name + program_name: []const u8, + + /// Type-erasure is needed so `top_level_commands` can hold a non-generic + /// function, as it is not possible to have it's parameter type depend on a + /// sibling field. Retrieve its concrete type via `.options()`. + raw_options: *align(8) const anyopaque, + /// This cannot be `SubCommand` directly as it would cause a dependency loop. + safety_check: if (core.runtime_safety) u8 else void, + + pub fn options(ctx: Context, comptime id: CommandId) ParsedOptions(id) { + assert(@intFromEnum(id) == ctx.safety_check); // incorrect memory access + return @as(*const ParsedOptions(id), @ptrCast(ctx.raw_options)).*; + } + + pub fn showHelp(ctx: Context, comptime id: CommandId) void { + getSubCommand(id).cli.showHelp(ctx.program_name); + } }; -pub fn main() !u8 { +const CommandId = @Type(.{ .@"enum" = .{ + .tag_type = u8, + .fields = fields: { + var fields: [top_level_commands.len]std.builtin.Type.EnumField = undefined; + var i: usize = 0; + for (top_level_commands) |tlc| switch (tlc) { + .separator => continue, + .command => |cmd| if (cmd.enabled) { + fields[i] = .{ + .name = cmd.name, + .value = i, + }; + i += 1; + }, + }; + break :fields &(fields[0..i].*); + }, + .decls = &.{}, + .is_exhaustive = true, +} }); + +fn getSubCommand(subcommand: CommandId) Command { + return for (top_level_commands) |tlc| switch (tlc) { + .separator => continue, + .command => |cmd| if (std.mem.eql(u8, cmd.name, @tagName(subcommand))) + break cmd, + } else unreachable; +} + +fn ParsedOptions(subcommand: CommandId) type { + return getSubCommand(subcommand).cli.Parsed(); +} + +pub fn main() !void { try tracer.init(); defer tracer.deinit(); try tracer.init_thread(null); defer tracer.deinit_thread(); - // unicode props is asserted to be initialized before core.Tokenizer is used - // this will result in a static page allocation, which is never cleaned up. + // 'unicode_props' is asserted to be initialized when core.Tokenizer is used. try core.unicode_props.init(); + defer core.unicode_props.deinit(); + // General Purpose Allocator provides great debugging assertions, but + // in Zig 0.14 it is unsuitible for release builds. To get reasonable + // performance, an arena over the page allocator is used. + // TODO: investigate other strategies, such as mi-malloc const use_gpa = builtin.mode == .Debug; var gpa = if (use_gpa) std.heap.GeneralPurposeAllocator(.{ @@ -38,18 +162,118 @@ pub fn main() !u8 { // Current working directory is required for CLI resolution. var cwd_buf: [std.fs.max_path_bytes]u8 = undefined; - const cwd = try std.posix.getcwd(&cwd_buf); + const cwd = std.posix.getcwd(&cwd_buf) catch |err| { + _ = @errorReturnTrace(); // TODO + Cli.fatal("Failed to query current working directory: {s}", .{@errorName(err)}); + }; // Command line argument parsing. - const program_name = "zig build compiler --"; - _, const args = try Cli.programArgsAlloc(alloc, program_name); + const program_base_name, const args = try Cli.programArgsAlloc(alloc, default_program_name); defer Cli.programArgsFree(alloc, args); - const opts = arg_config.parse(program_name, args); + + var joined_program_name: [512]u8 = undefined; + @memcpy(joined_program_name[0..program_base_name.len], program_base_name); + + const stderr = OutStream.getStderr(); + + if (args.len == 0) { + try showTopLevelHelp(stderr, program_base_name); + Cli.fatal("expected command argument", .{}); + } + + const command = std.meta.stringToEnum(CommandId, args[0]) orelse { + try showTopLevelHelp(stderr, program_base_name); + if (std.mem.eql(u8, args[0], "--help")) { + std.process.cleanExit(); + return; + } + + inline for (top_level_commands) |tlc| { + if (tlc == .command and !tlc.command.enabled) { + if (std.mem.eql(u8, args[0], tlc.command.name)) { + Cli.fatal("this build configuration does not include the {s} command", .{ + tlc.command.name, + }); + } + } + } + + Cli.fatal("unknown command '{s}'", .{args[0]}); + }; + + switch (command) { + // Help and version ignore all passed arguments + .help => { + try showTopLevelHelp(stderr, program_base_name); + std.process.cleanExit(); + }, + .version => { + const stdout = std.io.getStdOut(); + try stdout.writeAll(config.version_string ++ "\n"); + std.process.cleanExit(); + }, + inline else => |cmd| { + const def = comptime getSubCommand(cmd); + joined_program_name[program_base_name.len] = ' '; + @memcpy( + joined_program_name[program_base_name.len + 1 ..][0..def.name.len], + def.name, + ); + const name = joined_program_name[0 .. program_base_name.len + def.name.len + 1]; + const parsed = def.cli.parse(name, args[1..]); + try def.run(.{ + .cwd = cwd, + .gpa = alloc, + .raw_options = &parsed, + .safety_check = @intFromEnum(cmd), + .program_name = name, + }); + }, + } + + std.process.cleanExit(); +} + +fn showTopLevelHelp(out: OutStream, program_name: []const u8) !void { + try out.setColor(.bold); + try out.print("usage", .{}); + try out.setColor(.reset); + try out.print(": {s} [command] [options...]\n\n", .{program_name}); + try out.setColor(.bold); + try out.print("commands", .{}); + try out.setColor(.reset); + try out.writer.writeAll(comptime rest: { + var name_width: usize = 0; + for (top_level_commands) |tlc| switch (tlc) { + .separator => continue, + .command => |cmd| if (cmd.enabled) { + name_width = @max(cmd.name.len, name_width); + }, + }; + name_width += 4; + var rest: []const u8 = ":\n\n"; + for (top_level_commands) |tlc| switch (tlc) { + .separator => rest = rest ++ "\n", + .command => |cmd| if (cmd.enabled) { + rest = rest ++ " " ++ + cmd.name ++ (" " ** (name_width - cmd.name.len)) ++ + cmd.description ++ "\n"; + }, + }; + break :rest rest ++ "\n"; + }); +} + +fn cmdBuildOrRun(ctx: Context, comptime mode: enum { build, run }) !void { + const opts = ctx.options(@field(CommandId, @tagName(mode))); + const alloc = ctx.gpa; const file_path = try std.fs.path.resolve(alloc, &.{ - cwd, - opts.positionals orelse - arg_config.showHelpAndExit(program_name), + ctx.cwd, + opts.positionals orelse { + ctx.showHelp(@field(CommandId, @tagName(mode))); + Cli.fatal("missing entry file argument", .{}); + }, }); defer alloc.free(file_path); @@ -80,13 +304,19 @@ pub fn main() !u8 { std.debug.print("Transform failed:\n", .{}); try fail_report.dump(.initComp(&comp, main_file), comp.out); fail_report.deinit(comp.gpa); - return 1; + std.process.exit(1); }, error.ParseFail => { // Already printed. - return 1; + std.process.exit(1); + }, + else => |e| { + _ = @errorReturnTrace(); + Cli.fatal("failed to load {s}: {s}", .{ + file_path, + @errorName(e), + }); }, - else => return err, }; if (comp.options.dump_ir) { @@ -98,33 +328,56 @@ pub fn main() !u8 { comp.runAllJobs() catch |err| switch (err) { error.AnalysisFail => { // error is already printed - return 1; + std.process.exit(1); }, else => return err, }; - if (config.have_embedded_quickjs and (opts.run orelse false)) { - // Execute with QuickJS - var out: std.ArrayListUnmanaged(u8) = .{}; - defer out.deinit(alloc); - try js_backend.link(&comp, entry_point, out.writer(alloc).any()); - try out.append(alloc, 0); - try runWithQuickJs(out.items[0 .. out.items.len - 1 :0]); - } else { - // Output to standard out - const output = std.io.getStdOut().writer(); - var bufw = std.io.bufferedWriter(output); - const writer = bufw.writer(); - try js_backend.link(&comp, entry_point, writer.any()); - try bufw.flush(); + switch (mode) { + .run => { + // Execute with QuickJS + var out: std.ArrayListUnmanaged(u8) = .{}; + defer out.deinit(alloc); + try js_backend.link(&comp, entry_point, out.writer(alloc).any()); + try out.append(alloc, 0); + try runWithQuickJs(out.items[0 .. out.items.len - 1 :0]); + }, + .build => { + // Output to standard out + const output = std.io.getStdOut().writer(); + var bufw = std.io.bufferedWriter(output); + const writer = bufw.writer(); + try js_backend.link(&comp, entry_point, writer.any()); + try bufw.flush(); + }, } } std.process.cleanExit(); - return 0; } +fn cmdBuild(ctx: Context) !void { + try cmdBuildOrRun(ctx, .build); +} + +fn cmdRun(ctx: Context) !void { + try cmdBuildOrRun(ctx, .run); +} + +// fn cmdFmt(ctx: Context) !void { +// const opts = ctx.options(.fmt); +// _ = opts; // autofix +// } + +const arg_config: Cli = .{ + .options = .{} ++ Cli.Option.optional(config.have_embedded_quickjs, &.{ + .{ .name = "--run", .desc = "Execute with QuickJS" }, + }), + .positionals = .single, +}; + fn runWithQuickJs(js: [:0]const u8) !void { + comptime assert(config.have_embedded_quickjs); const qjs = @import("quickjs"); const runtime = qjs.JS_NewRuntime() orelse @@ -159,20 +412,24 @@ fn runWithQuickJs(js: [:0]const u8) !void { const slice = std.mem.span(exception_str); - std.debug.print("Exception: {s}\n", .{slice}); - - std.process.exit(1); + Cli.fatal("{s}", .{slice}); } } +const config = @import("config"); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; + const core = @import("core"); +const OutStream = core.OutStream; const Comp = core.Comp; const Report = core.Report; const tracer = @import("tracer"); pub const tracer_impl = @field(tracer, @tagName(@import("config").tracing_mode)); -const std = @import("std"); const builtin = @import("builtin"); const Cli = @import("Cli.zig");