// Startup argument parsing for the command-line contract owned by // `spec/model/cli.zon`. This module decides the invocation and every usage // rejection. It performs no input or output, allocates nothing, and therefore // cannot widen what the process later does. const std = @import("std"); const spec_data = @import("spec_data"); /// Arguments accepted from one invocation, bounded by the registered capacity. pub const arguments_max: u32 = @intCast(spec_data.limit_value.arguments_per_invocation); /// Bytes of one argument that a startup diagnostic may echo. Only diagnostics /// truncate, as required by the logging policy in `spec/LIMITS.md`. pub const echo_bytes_max: u32 = @intCast(spec_data.limit_value.diagnostic_argument_bytes); comptime { std.debug.assert(spec_data.cli_long_options_only); std.debug.assert(spec_data.cli_option_terminator); std.debug.assert(!spec_data.cli_operands_allowed); std.debug.assert(!spec_data.cli_duplicate_options_allowed); std.debug.assert(!spec_data.cli_unattended_cleanup); std.debug.assert(arguments_max > 0); std.debug.assert(echo_bytes_max > "".len); } /// What the accepted invocation asks the process to do. pub const Mode = enum { help, version, interactive }; /// One accepted invocation. Informational modes bypass every startup subsystem. pub const Invocation = struct { mode: Mode = .interactive, dry_run: bool = false, no_color: bool = false, ascii: bool = false, config_path: ?[]const u8 = null, }; /// Why an invocation is rejected before any startup subsystem runs. pub const Reason = enum { unknown_option, inline_value, missing_value, empty_value, duplicate_option, exclusive_option, operand, short_option, too_many_arguments, /// One concise reason, free of operator-supplied bytes. pub fn message(reason: Reason) []const u8 { return switch (reason) { .unknown_option => "unknown option", .inline_value => "option value must be a separate argument", .missing_value => "option requires a value", .empty_value => "option value must not be empty", .duplicate_option => "duplicate option", .exclusive_option => "option must appear alone", .operand => "operands are not supported", .short_option => "short options are not supported", .too_many_arguments => "too many arguments", }; } }; /// A rejected invocation and the bounded, sanitized argument it names. pub const Failure = struct { reason: Reason, echo: Echo, }; /// The parse outcome. Exactly one of the two states is reachable per invocation. pub const Result = union(enum) { invocation: Invocation, failure: Failure, }; /// A sanitized, bounded copy of one operator-supplied argument. Diagnostics /// never write raw argument bytes to a terminal. pub const Echo = struct { bytes: [echo_bytes_max]u8 = [_]u8{0} ** echo_bytes_max, length: u32 = 0, truncated: bool = false, pub fn text(echo: *const Echo) []const u8 { std.debug.assert(echo.length <= echo_bytes_max); std.debug.assert(echo.bytes.len == echo_bytes_max); return echo.bytes[0..echo.length]; } }; /// Decide the invocation named by `arguments`, which excludes the program name. pub fn parse(arguments: []const []const u8) Result { std.debug.assert(arguments.len <= arguments_max); var invocation: Invocation = .{}; var seen = [_]bool{false} ** spec_data.cli_options.len; var index: u32 = 0; while (index < arguments.len) { const argument = arguments[index]; index += 1; if (std.mem.eql(u8, argument, "--")) { if (index == arguments.len) continue; return reject(.operand, arguments[index]); } if (!std.mem.startsWith(u8, argument, "--")) { if (argument.len > 1 and argument[0] == '-') return reject(.short_option, argument); return reject(.operand, argument); } if (std.mem.indexOfScalar(u8, argument, '=')) |equals| { const name = argument[0..equals]; if (optionOf(name) == null) return reject(.unknown_option, name); return reject(.inline_value, name); } const option = optionOf(argument) orelse return reject(.unknown_option, argument); const slot = @intFromEnum(option.id); std.debug.assert(slot < seen.len); if (option.must_appear_alone and arguments.len != 1) { return reject(.exclusive_option, argument); } if (seen[slot]) return reject(.duplicate_option, argument); seen[slot] = true; if (option.argument == .none) { apply(&invocation, option.id, null); continue; } if (index == arguments.len) return reject(.missing_value, argument); const value = arguments[index]; index += 1; if (value.len == 0) return reject(.empty_value, argument); apply(&invocation, option.id, value); } return .{ .invocation = invocation }; } /// Record one accepted option. Every registered identifier is handled here, so /// adding a registry option fails compilation rather than being ignored. fn apply(invocation: *Invocation, id: spec_data.CliOptionId, value: ?[]const u8) void { std.debug.assert(invocation.mode == .interactive); switch (id) { .dry_run => { std.debug.assert(value == null); invocation.dry_run = true; }, .no_color => { std.debug.assert(value == null); invocation.no_color = true; }, .ascii => { std.debug.assert(value == null); invocation.ascii = true; }, .config => { std.debug.assert(value.?.len > 0); invocation.config_path = value; }, .help => invocation.mode = .help, .version => invocation.mode = .version, } } /// Look up one registered long option by its exact spelling. fn optionOf(spelling: []const u8) ?spec_data.CliOption { std.debug.assert(spec_data.cli_options.len > 0); if (!std.mem.startsWith(u8, spelling, "--")) return null; for (spec_data.cli_options) |option| { if (std.mem.eql(u8, option.spelling, spelling)) return option; } return null; } fn reject(reason: Reason, argument: []const u8) Result { std.debug.assert(@intFromEnum(reason) <= @intFromEnum(Reason.too_many_arguments)); std.debug.assert(reason != .too_many_arguments); return .{ .failure = .{ .reason = reason, .echo = echoOf(argument) } }; } /// Sanitize one argument for display: strict UTF-8 decoding, bounded escapes for /// C0, DEL, C1, bidi-control, and noncharacter code points, and `?` for every /// maximal invalid sequence, as required by the ASCII display track. pub fn echoOf(argument: []const u8) Echo { var echo: Echo = .{}; var index: u32 = 0; while (index < argument.len) { const remaining = argument[index..]; const length = std.unicode.utf8ByteSequenceLength(remaining[0]) catch { if (!append(&echo, "?")) return echo; index += 1; continue; }; if (length > remaining.len) { if (!append(&echo, "?")) return echo; index += 1; continue; } const sequence = remaining[0..length]; const code_point = std.unicode.utf8Decode(sequence) catch { if (!append(&echo, "?")) return echo; index += 1; continue; }; if (escapes(code_point)) { if (!appendEscape(&echo, code_point)) return echo; } else { if (!append(&echo, sequence)) return echo; } index += length; } return echo; } /// Code points that never reach a terminal unescaped. fn escapes(code_point: u21) bool { std.debug.assert(code_point <= 0x10ffff); if (code_point < 0x20) return true; if (code_point == 0x7f) return true; if (code_point >= 0x80 and code_point <= 0x9f) return true; if (code_point == 0x61c) return true; if (code_point >= 0x200e and code_point <= 0x200f) return true; if (code_point >= 0x202a and code_point <= 0x202e) return true; if (code_point >= 0x2066 and code_point <= 0x2069) return true; if (code_point >= 0xfdd0 and code_point <= 0xfdef) return true; if (code_point & 0xfffe == 0xfffe) return true; return false; } /// Append one escape. Returns false when the registered bound is reached. fn appendEscape(echo: *Echo, code_point: u21) bool { std.debug.assert(escapes(code_point)); std.debug.assert(echo.length <= echo_bytes_max); var buffer: ["".len]u8 = undefined; const escape = if (code_point < 0x100) std.fmt.bufPrint(&buffer, "<{X:0>2}>", .{code_point}) catch unreachable else std.fmt.bufPrint(&buffer, "4}>", .{code_point}) catch unreachable; return append(echo, escape); } /// Append sanitized bytes, or mark the echo truncated when they do not fit. fn append(echo: *Echo, bytes: []const u8) bool { std.debug.assert(bytes.len > 0); std.debug.assert(echo.length <= echo_bytes_max); if (echo.length + bytes.len > echo_bytes_max) { echo.truncated = true; return false; } @memcpy(echo.bytes[echo.length..][0..bytes.len], bytes); echo.length += @intCast(bytes.len); return true; }