diff --git a/packages/bun-polyfills/src/modules/bun.ts b/packages/bun-polyfills/src/modules/bun.ts index 6d7fd1a076..7b15ce2b10 100644 --- a/packages/bun-polyfills/src/modules/bun.ts +++ b/packages/bun-polyfills/src/modules/bun.ts @@ -158,6 +158,8 @@ export const openInEditor = ((file: string, opts?: EditorOptions) => { else openEditor(target, { editor: process.env.TERM_PROGRAM ?? process.env.VISUAL ?? process.env.EDITOR ?? 'vscode' }); }) satisfies typeof Bun.openInEditor; +export const parseArgs = util.parseArgs; + export const serve = (() => { throw new NotImplementedError('Bun.serve', serve); }) satisfies typeof Bun.serve; export const file = ((path: string | URL | Uint8Array | ArrayBufferLike | number, options?: BlobPropertyBag): BunFileBlob => { diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 3d4b0d715e..daf677d106 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -4433,6 +4433,34 @@ declare module "bun" { buffer: ArrayBufferView | ArrayBufferLike, offset?: number, ): number; + + /** + * Provides a higher level API for command-line argument parsing than interacting + * with `process.argv` directly. Takes a specification for the expected arguments + * and returns a structured object with the parsed options and positionals. + * + * ```js + * const args = ['-f', '--bar', 'b']; + * const options = { + * foo: { + * type: 'boolean', + * short: 'f', + * }, + * bar: { + * type: 'string', + * }, + * }; + * const { + * values, + * positionals, + * } = Bun.parseArgs({ args, options }); + * console.log(values, positionals); + * // Prints: { foo: true, bar: 'b' } [] + * ``` + * @param config Used to provide arguments for parsing and to configure the parser. + * @return The parsed command line arguments + */ + export const parseArgs: typeof import("util").parseArgs; } type TypedArray = diff --git a/packages/bun-types/util.d.ts b/packages/bun-types/util.d.ts index d037cf4171..1234c99012 100644 --- a/packages/bun-types/util.d.ts +++ b/packages/bun-types/util.d.ts @@ -1167,6 +1167,258 @@ declare module "util" { */ const custom: unique symbol; } + + //// parseArgs + /** + * Provides a higher level API for command-line argument parsing than interacting + * with `process.argv` directly. Takes a specification for the expected arguments + * and returns a structured object with the parsed options and positionals. + * + * ```js + * import { parseArgs } from 'node:util'; + * const args = ['-f', '--bar', 'b']; + * const options = { + * foo: { + * type: 'boolean', + * short: 'f', + * }, + * bar: { + * type: 'string', + * }, + * }; + * const { + * values, + * positionals, + * } = parseArgs({ args, options }); + * console.log(values, positionals); + * // Prints: [Object: null prototype] { foo: true, bar: 'b' } [] + * ``` + * @since v18.3.0, v16.17.0 + * @param config Used to provide arguments for parsing and to configure the parser. + * @return The parsed command line arguments: + */ + export function parseArgs( + config?: T, + ): ParsedResults; + interface ParseArgsOptionConfig { + /** + * Type of argument. + */ + type: "string" | "boolean"; + /** + * Whether this option can be provided multiple times. + * If `true`, all values will be collected in an array. + * If `false`, values for the option are last-wins. + * @default false. + */ + multiple?: boolean | undefined; + /** + * A single character alias for the option. + */ + short?: string | undefined; + /** + * The default option value when it is not set by args. + * It must be of the same type as the the `type` property. + * When `multiple` is `true`, it must be an array. + * @since v18.11.0 + */ + default?: string | boolean | string[] | boolean[] | undefined; + } + interface ParseArgsOptionsConfig { + [longOption: string]: ParseArgsOptionConfig; + } + export interface ParseArgsConfig { + /** + * Array of argument strings. + */ + args?: string[] | undefined; + /** + * Used to describe arguments known to the parser. + */ + options?: ParseArgsOptionsConfig | undefined; + /** + * Should an error be thrown when unknown arguments are encountered, + * or when arguments are passed that do not match the `type` configured in `options`. + * @default true + */ + strict?: boolean | undefined; + /** + * Whether this command accepts positional arguments. + */ + allowPositionals?: boolean | undefined; + /** + * Return the parsed tokens. This is useful for extending the built-in behavior, + * from adding additional checks through to reprocessing the tokens in different ways. + * @default false + */ + tokens?: boolean | undefined; + } + /* + IfDefaultsTrue and IfDefaultsFalse are helpers to handle default values for missing boolean properties. + TypeScript does not have exact types for objects: https://github.com/microsoft/TypeScript/issues/12936 + This means it is impossible to distinguish between "field X is definitely not present" and "field X may or may not be present". + But we expect users to generally provide their config inline or `as const`, which means TS will always know whether a given field is present. + So this helper treats "not definitely present" (i.e., not `extends boolean`) as being "definitely not present", i.e. it should have its default value. + This is technically incorrect but is a much nicer UX for the common case. + The IfDefaultsTrue version is for things which default to true; the IfDefaultsFalse version is for things which default to false. + */ + type IfDefaultsTrue = T extends true + ? IfTrue + : T extends false + ? IfFalse + : IfTrue; + + // we put the `extends false` condition first here because `undefined` compares like `any` when `strictNullChecks: false` + type IfDefaultsFalse = T extends false + ? IfFalse + : T extends true + ? IfTrue + : IfFalse; + + type ExtractOptionValue< + T extends ParseArgsConfig, + O extends ParseArgsOptionConfig, + > = IfDefaultsTrue< + T["strict"], + O["type"] extends "string" + ? string + : O["type"] extends "boolean" + ? boolean + : string | boolean, + string | boolean + >; + + type ParsedValues = IfDefaultsTrue< + T["strict"], + unknown, + { [longOption: string]: undefined | string | boolean } + > & + (T["options"] extends ParseArgsOptionsConfig + ? { + -readonly [LongOption in keyof T["options"]]: IfDefaultsFalse< + T["options"][LongOption]["multiple"], + undefined | Array>, + undefined | ExtractOptionValue + >; + } + : {}); + + type ParsedPositionals = IfDefaultsTrue< + T["strict"], + IfDefaultsFalse, + IfDefaultsTrue + >; + + type PreciseTokenForOptions< + K extends string, + O extends ParseArgsOptionConfig, + > = O["type"] extends "string" + ? { + kind: "option"; + index: number; + name: K; + rawName: string; + value: string; + inlineValue: boolean; + } + : O["type"] extends "boolean" + ? { + kind: "option"; + index: number; + name: K; + rawName: string; + value: undefined; + inlineValue: undefined; + } + : OptionToken & { name: K }; + + type TokenForOptions< + T extends ParseArgsConfig, + K extends keyof T["options"] = keyof T["options"], + > = K extends unknown + ? T["options"] extends ParseArgsOptionsConfig + ? PreciseTokenForOptions + : OptionToken + : never; + + type ParsedOptionToken = IfDefaultsTrue< + T["strict"], + TokenForOptions, + OptionToken + >; + + type ParsedPositionalToken = IfDefaultsTrue< + T["strict"], + IfDefaultsFalse< + T["allowPositionals"], + { kind: "positional"; index: number; value: string }, + never + >, + IfDefaultsTrue< + T["allowPositionals"], + { kind: "positional"; index: number; value: string }, + never + > + >; + + type ParsedTokens = Array< + | ParsedOptionToken + | ParsedPositionalToken + | { kind: "option-terminator"; index: number } + >; + + type PreciseParsedResults = IfDefaultsFalse< + T["tokens"], + { + values: ParsedValues; + positionals: ParsedPositionals; + tokens: ParsedTokens; + }, + { + values: ParsedValues; + positionals: ParsedPositionals; + } + >; + + type OptionToken = + | { + kind: "option"; + index: number; + name: string; + rawName: string; + value: string; + inlineValue: boolean; + } + | { + kind: "option"; + index: number; + name: string; + rawName: string; + value: undefined; + inlineValue: undefined; + }; + + type Token = + | OptionToken + | { kind: "positional"; index: number; value: string } + | { kind: "option-terminator"; index: number }; + + // If ParseArgsConfig extends T, then the user passed config constructed elsewhere. + // So we can't rely on the `"not definitely present" implies "definitely not present"` assumption mentioned above. + type ParsedResults = ParseArgsConfig extends T + ? { + values: { + [longOption: string]: + | undefined + | string + | boolean + | Array; + }; + positionals: string[]; + tokens?: Token[]; + } + : PreciseParsedResults; + export interface EncodeIntoResult { /** * The read Unicode code units of input. diff --git a/src/bun.js/api/bun.zig b/src/bun.js/api/bun.zig index 54cf5c5cab..78dfdf1175 100644 --- a/src/bun.js/api/bun.zig +++ b/src/bun.js/api/bun.zig @@ -30,6 +30,7 @@ pub const BunObject = struct { pub const mmap = Bun.mmapFile; pub const nanoseconds = Bun.nanoseconds; pub const openInEditor = Bun.openInEditor; + pub const parseArgs = Bun.parseArgs; pub const registerMacro = Bun.registerMacro; pub const resolve = Bun.resolve; pub const resolveSync = Bun.resolveSync; @@ -142,6 +143,7 @@ pub const BunObject = struct { @export(BunObject.mmap, .{ .name = callbackName("mmap") }); @export(BunObject.nanoseconds, .{ .name = callbackName("nanoseconds") }); @export(BunObject.openInEditor, .{ .name = callbackName("openInEditor") }); + @export(BunObject.parseArgs, .{ .name = callbackName("parseArgs") }); @export(BunObject.registerMacro, .{ .name = callbackName("registerMacro") }); @export(BunObject.resolve, .{ .name = callbackName("resolve") }); @export(BunObject.resolveSync, .{ .name = callbackName("resolveSync") }); @@ -239,6 +241,7 @@ const max_addressible_memory = std.math.maxInt(u56); const glob = @import("../../glob.zig"); const Async = bun.Async; const SemverObject = @import("../../install/semver.zig").SemverObject; +const parseArgsImpl = @import("../node/util/parse_args.zig").parseArgs; threadlocal var css_imports_list_strings: [512]ZigString = undefined; threadlocal var css_imports_list: [512]Api.StringPointer = undefined; @@ -661,6 +664,21 @@ pub fn openInEditor( return JSC.JSValue.jsUndefined(); } +pub fn parseArgs( + globalThis: js.JSContextRef, + callframe: *JSC.CallFrame, +) callconv(.C) JSValue { + const arguments = callframe.arguments(1).slice(); + const config = if (arguments.len > 0) arguments[0] else JSValue.undefined; + return parseArgsImpl(globalThis, config) catch |err| { + // these two types of error will already throw their own js exception + if (err != error.ParseError and err != error.InvalidArgument) { + globalThis.throwOutOfMemory(); + } + return JSValue.undefined; + }; +} + pub fn getPublicPath(to: string, origin: URL, comptime Writer: type, writer: Writer) void { return getPublicPathWithAssetPrefix(to, VirtualMachine.get().bundler.fs.top_level_dir, origin, VirtualMachine.get().bundler.options.routes.asset_prefix_path, comptime Writer, writer); } diff --git a/src/bun.js/bindings/BunObject+exports.h b/src/bun.js/bindings/BunObject+exports.h index 2a8c19981b..1615f86b44 100644 --- a/src/bun.js/bindings/BunObject+exports.h +++ b/src/bun.js/bindings/BunObject+exports.h @@ -53,6 +53,7 @@ macro(mmap) \ macro(nanoseconds) \ macro(openInEditor) \ + macro(parseArgs) \ macro(registerMacro) \ macro(resolve) \ macro(resolveSync) \ diff --git a/src/bun.js/bindings/BunObject.cpp b/src/bun.js/bindings/BunObject.cpp index 9ea40c79c6..1f8584c814 100644 --- a/src/bun.js/bindings/BunObject.cpp +++ b/src/bun.js/bindings/BunObject.cpp @@ -615,6 +615,7 @@ JSC_DEFINE_HOST_FUNCTION(functionHashCode, nanoseconds functionBunNanoseconds DontDelete|Function 0 openInEditor BunObject_callback_openInEditor DontDelete|Function 1 origin BunObject_getter_wrap_origin DontDelete|PropertyCallback + parseArgs BunObject_callback_parseArgs DontDelete|Function 1 password constructPasswordObject DontDelete|PropertyCallback pathToFileURL functionPathToFileURL DontDelete|Function 1 peek constructBunPeekObject DontDelete|PropertyCallback diff --git a/src/bun.js/bindings/bindings.zig b/src/bun.js/bindings/bindings.zig index 2e7914a12d..ce9b0f4732 100644 --- a/src/bun.js/bindings/bindings.zig +++ b/src/bun.js/bindings/bindings.zig @@ -260,12 +260,12 @@ pub const ZigString = extern struct { return this.slice()[0] == char; } - pub fn substringWithLen(this: ZigString, offset: usize, len: usize) ZigString { + pub fn substringWithLen(this: ZigString, start_index: usize, end_index: usize) ZigString { if (this.is16Bit()) { - return ZigString.from16Slice(this.utf16SliceAligned()[@min(this.len, offset)..len]); + return ZigString.from16Slice(this.utf16SliceAligned()[start_index..end_index]); } - var out = ZigString.init(this.slice()[@min(this.len, offset)..len]); + var out = ZigString.init(this.slice()[start_index..end_index]); if (this.isUTF8()) { out.markUTF8(); } @@ -277,8 +277,8 @@ pub const ZigString = extern struct { return out; } - pub fn substring(this: ZigString, offset: usize) ZigString { - return this.substringWithLen(offset, this.len); + pub fn substring(this: ZigString, start_index: usize) ZigString { + return this.substringWithLen(@min(this.len, start_index), this.len); } pub fn maxUTF8ByteLength(this: ZigString) usize { @@ -3557,10 +3557,12 @@ pub const JSValue = enum(JSValueReprInt) { extern fn JSC__JSValue__constructEmptyObject(globalObject: *JSGlobalObject, prototype: [*c]JSC.JSObject, len: usize) JSValue; + /// Creates a new empty object with the specified prototype, or no prototype if null pub fn constructEmptyObject(global: *JSGlobalObject, prototype: ?*JSC.JSObject, len: usize) JSValue { return JSC__JSValue__constructEmptyObject(global, prototype, len); } + /// Creates a new empty object, with Object as its prototype pub fn createEmptyObject(global: *JSGlobalObject, len: usize) JSValue { return cppFn("createEmptyObject", .{ global, len }); } diff --git a/src/bun.js/node/nodejs_error_code.zig b/src/bun.js/node/nodejs_error_code.zig index 5c54791ee2..893e806c4f 100644 --- a/src/bun.js/node/nodejs_error_code.zig +++ b/src/bun.js/node/nodejs_error_code.zig @@ -1094,4 +1094,13 @@ pub const Code = enum { /// Used when an attempt is made to use a zlib object after it has already been closed. /// CPU USAGE ERR_CPU_USAGE, + + /// Used by node:util parseArgs + ERR_PARSE_ARGS_INVALID_OPTION_VALUE, + + /// Used by node:util parseArgs + ERR_PARSE_ARGS_UNKNOWN_OPTION, + + /// Used by node:util parseArgs + ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL, }; diff --git a/src/bun.js/node/util/parse_args.zig b/src/bun.js/node/util/parse_args.zig new file mode 100644 index 0000000000..13c34a2ebe --- /dev/null +++ b/src/bun.js/node/util/parse_args.zig @@ -0,0 +1,684 @@ +const std = @import("std"); +const bun = @import("root").bun; +const string = bun.string; +const strings = bun.strings; +const String = bun.String; +const JSC = bun.JSC; +const JSValue = JSC.JSValue; +const JSGlobalObject = JSC.JSGlobalObject; +const ZigString = JSC.ZigString; + +const validators = @import("./validators.zig"); +const validateArray = validators.validateArray; +const validateBoolean = validators.validateBoolean; +const validateBooleanArray = validators.validateBooleanArray; +const validateObject = validators.validateObject; +const validateString = validators.validateString; +const validateStringArray = validators.validateStringArray; +const validateStringEnum = validators.validateStringEnum; + +const utils = @import("./parse_args_utils.zig"); +const OptionValueType = utils.OptionValueType; +const OptionDefinition = utils.OptionDefinition; +const findOptionByShortName = utils.findOptionByShortName; +const classifyToken = utils.classifyToken; +const isOptionLikeValue = utils.isOptionLikeValue; + +const log = bun.Output.scoped(.parseArgs, true); + +const ParseArgsError = error{ParseError}; + +/// Represents a slice of a JSValue array +const ArgsSlice = struct { + array: JSValue, + start: usize, + end: usize, +}; + +const TokenKind = enum { positional, option, @"option-terminator" }; +const Token = union(TokenKind) { + positional: struct { index: i32, value: JSValue }, + option: OptionToken, + @"option-terminator": struct { index: i32 }, +}; + +const OptionToken = struct { + index: i32, + name: JSValue, + parse_type: enum { + lone_short_option, + short_option_and_value, + lone_long_option, + long_option_and_value, + }, + value: JSValue, + inline_value: bool, + option_idx: ?usize, + + /// The full raw arg string (e.g. "--arg=1"). + /// It might not exist as-is on the input "args" list, like in the case of short option groups + raw: JSValue, + + /// Returns the name of the arg including any dashes and excluding inline values, as a bun string + /// + /// Note: callee must call `.deref()` on the resulting string once done + fn makeRawNameString(this: *const OptionToken, globalThis: *JSGlobalObject) !String { + switch (this.parse_type) { + .lone_short_option, .lone_long_option => { + var str = this.raw.toBunString(globalThis); + str.ref(); + return str; + }, + .short_option_and_value => { + const raw = this.raw.toBunString(globalThis); + return try String.createFromConcat(globalThis.allocator(), &[_]String{ String.static("-"), raw.substringWithLen(1, 2) }); + }, + .long_option_and_value => { + const raw = this.raw.toBunString(globalThis); + const equal_index = raw.indexOfCharU8('=').?; + var str = raw.substringWithLen(0, equal_index); + str.ref(); + return str; + }, + } + } + + /// Returns the name of the arg including any dashes and excluding inline values, as a JSValue + fn makeRawNameJSValue(this: *const OptionToken, globalThis: *JSGlobalObject) !JSValue { + return switch (this.parse_type) { + .lone_short_option, .lone_long_option => this.raw, + else => { + var str = try this.makeRawNameString(globalThis); + defer str.deref(); + return str.toJSConst(globalThis); + }, + }; + } +}; + +pub fn findOptionByLongName(long_name: String, options: []const OptionDefinition) ?usize { + for (options, 0..) |option, i| { + if (long_name.eql(option.long_name)) { + return i; + } + } + return null; +} + +/// Gets the default args from the process argv +fn getDefaultArgs(globalThis: *JSGlobalObject) !ArgsSlice { + // Work out where to slice process.argv for user supplied arguments + + // Check options for scenarios where user CLI args follow executable + const argv: JSValue = JSC.Node.Process.getArgv(globalThis); + + //var found = false; + //var iter = argv.arrayIterator(globalThis); + //while (iter.next()) |arg| { + // const str = arg.toBunString(globalThis); + // if (str.eqlComptime("-e") or str.eqlComptime("--eval") or str.eqlComptime("-p") or str.eqlComptime("--print")) { + // found = true; + // break; + // } + //} + // Normally first two arguments are executable and script, then CLI arguments + //args_offset.* = if (found) 1 else 2; + + // argv[0] is the bun executable name + // argv[1] is the script path, or a placeholder in case of eval + // so actual args start from argv[2] + return .{ .array = argv, .start = 2, .end = @intCast(argv.getLength(globalThis)) }; +} + +/// In strict mode, throw for possible usage errors like "--foo --bar" where foo was defined as a string-valued arg +fn checkOptionLikeValue(globalThis: *JSGlobalObject, token: OptionToken) !void { + if (!token.inline_value and isOptionLikeValue(token.value.toBunString(globalThis))) { + const raw_name = try token.makeRawNameString(globalThis); + defer raw_name.deref(); + + // Only show short example if user used short option. + var err: JSValue = undefined; + if (raw_name.hasPrefixComptime("--")) { + err = JSC.toTypeError( + JSC.Node.ErrorCode.ERR_PARSE_ARGS_INVALID_OPTION_VALUE, + "Option '{}' argument is ambiguous.\nDid you forget to specify the option argument for '{}'?\nTo specify an option argument starting with a dash use '{}=-XYZ'.", + .{ raw_name, raw_name, raw_name }, + globalThis, + ); + } else { + const token_name = token.name.toBunString(globalThis); + err = JSC.toTypeError( + JSC.Node.ErrorCode.ERR_PARSE_ARGS_INVALID_OPTION_VALUE, + "Option '{}' argument is ambiguous.\nDid you forget to specify the option argument for '{}'?\nTo specify an option argument starting with a dash use '--{}=-XYZ' or '{}-XYZ'.", + .{ raw_name, raw_name, token_name, raw_name }, + globalThis, + ); + } + globalThis.vm().throwError(globalThis, err); + return error.ParseError; + } +} + +/// In strict mode, throw for usage errors. +fn checkOptionUsage(globalThis: *JSGlobalObject, options: []const OptionDefinition, allow_positionals: bool, token: OptionToken) !void { + if (token.option_idx) |option_idx| { + const option = options[option_idx]; + switch (option.type) { + .string => if (!token.value.isString()) { + const err = JSC.toTypeError( + JSC.Node.ErrorCode.ERR_PARSE_ARGS_INVALID_OPTION_VALUE, + "Option '{s}{s}{s}--{s} ' argument missing", + .{ + if (option.short_name != null) "-" else "", + if (option.short_name) |chr| &[_]u8{chr} else "", + if (option.short_name != null) ", " else "", + token.name.toBunString(globalThis), + }, + globalThis, + ); + globalThis.vm().throwError(globalThis, err); + return error.ParseError; + }, + .boolean => if (!token.value.isUndefined()) { + const err = JSC.toTypeError( + JSC.Node.ErrorCode.ERR_PARSE_ARGS_INVALID_OPTION_VALUE, + "Option '{s}{s}{s}--{s}' does not take an argument", + .{ + if (option.short_name != null) "-" else "", + if (option.short_name) |chr| &[_]u8{chr} else "", + if (option.short_name != null) ", " else "", + token.name.toBunString(globalThis), + }, + globalThis, + ); + globalThis.vm().throwError(globalThis, err); + return error.ParseError; + }, + } + } else { + const raw_name = try token.makeRawNameString(globalThis); + defer raw_name.deref(); + + const err = if (allow_positionals) (JSC.toTypeError( + JSC.Node.ErrorCode.ERR_PARSE_ARGS_UNKNOWN_OPTION, + "Unknown option '{}'. To specify a positional 'argument starting with a '-', place it at the end of the command after '--', as in '-- \"{}\"", + .{ raw_name, raw_name }, + globalThis, + )) else (JSC.toTypeError( + JSC.Node.ErrorCode.ERR_PARSE_ARGS_UNKNOWN_OPTION, + "Unknown option '{}'", + .{raw_name}, + globalThis, + )); + globalThis.vm().throwError(globalThis, err); + return error.ParseError; + } +} + +/// Store the option value in `values`. +/// Parameters: +/// - `long_option`: long option name e.g. "foo" +/// - `optionValue`: value from user args +/// - `options`: option configs, from `parseArgs({ options })` +/// - `values`: option values returned in `values` by parseArgs +fn storeOption(globalThis: *JSGlobalObject, long_option: JSValue, option_value: JSValue, option_idx: ?usize, options: []const OptionDefinition, values: JSValue) void { + if (long_option.toBunString(globalThis).eqlComptime("__proto__")) { + return; + } + + // We store based on the option value rather than option type, + // preserving the users intent for author to deal with. + const new_value = if (option_value.isUndefined()) JSValue.true else option_value; + + const is_multiple = if (option_idx) |idx| options[idx].multiple else false; + if (is_multiple) { + // Always store value in array, including for boolean. + // values[long_option] starts out not present, + // first value is added as new array [new_value], + // subsequent values are pushed to existing array. + var key = long_option.toBunString(globalThis); + if (values.getOwn(globalThis, key)) |value_list| { + value_list.push(globalThis, new_value); + } else { + var key_zig = key.toZigString(); + var value_list = JSValue.createEmptyArray(globalThis, 1); + value_list.putIndex(globalThis, 0, new_value); + values.put(globalThis, &key_zig, value_list); + } + } else { + var key_zig = long_option.getZigString(globalThis); + values.put(globalThis, &key_zig, new_value); + } +} + +fn parseOptionDefinitions(globalThis: *JSGlobalObject, options_obj: JSValue, option_definitions: *std.ArrayList(OptionDefinition)) !void { + try validateObject(globalThis, options_obj, "options", .{}, .{}); + + var iter = JSC.JSPropertyIterator(.{ + .skip_empty_name = false, + .include_value = true, + }).init(globalThis, options_obj.asObjectRef()); + defer iter.deinit(); + + while (iter.next()) |long_option| { + var option = OptionDefinition{ + .long_name = String.init(long_option), + }; + + const obj: JSValue = iter.value; + try validateObject(globalThis, obj, "options.{s}", .{option.long_name}, .{}); + + // type field is required + const option_type = obj.getOwn(globalThis, "type") orelse JSValue.undefined; + option.type = try validateStringEnum(OptionValueType, globalThis, option_type, "options.{s}.type", .{option.long_name}); + + if (obj.getOwn(globalThis, "short")) |short_option| { + try validateString(globalThis, short_option, "options.{s}.short", .{option.long_name}); + var short_option_str = short_option.toBunString(globalThis); + if (short_option_str.length() != 1) { + const err = JSC.toTypeError(JSC.Node.ErrorCode.ERR_INVALID_ARG_VALUE, "options.{s}.short must be a single character", .{option.long_name}, globalThis); + globalThis.vm().throwError(globalThis, err); + return error.ParseError; + } + option.short_name = short_option_str.charAtU8(0); + } + + if (obj.getOwn(globalThis, "multiple")) |multiple_value| { + if (!multiple_value.isUndefined()) { + option.multiple = try validateBoolean(globalThis, multiple_value, "options.{s}.multiple", .{option.long_name}); + } + } + + if (obj.getOwn(globalThis, "default")) |default_value| { + if (!default_value.isUndefined()) { + switch (option.type) { + .string => { + if (option.multiple) { + _ = try validateStringArray(globalThis, default_value, "options.{s}.default", .{option.long_name}); + } else { + try validateString(globalThis, default_value, "options.{s}.default", .{option.long_name}); + } + }, + .boolean => { + if (option.multiple) { + _ = try validateBooleanArray(globalThis, default_value, "options.{s}.default", .{option.long_name}); + } else { + _ = try validateBoolean(globalThis, default_value, "options.{s}.default", .{option.long_name}); + } + }, + } + option.default_value = default_value; + } + } + + log("[OptionDef] \"{s}\" (type={s}, short={s}, multiple={d}, default={?})", .{ + String.init(long_option), + @tagName(option.type), + if (option.short_name) |chr| &[_]u8{chr} else "none", + @intFromBool(option.multiple), + option.default_value, + }); + + try option_definitions.append(option); + } +} + +/// Process the args string-array and build an array identified tokens: +/// - option (along with value, if any) +/// - positional +/// - option-terminator +fn tokenizeArgs(globalThis: *JSGlobalObject, args: ArgsSlice, options: []const OptionDefinition, tokens: *std.ArrayList(Token)) !void { + var index: i32 = -1; + var group_count: i32 = 0; + + // build a queue of args to process, because new args can be inserted during the processing + var queue_allocator = std.heap.stackFallback(32 * @sizeOf(JSValue), globalThis.allocator()); + var queue = try std.ArrayList(JSValue).initCapacity(queue_allocator.get(), args.end - args.start); + defer queue.deinit(); + for (args.start..args.end) |i| { + queue.appendAssumeCapacity(args.array.getIndex(globalThis, @truncate(i))); + } + + var queue_pos: usize = 0; + + while (queue_pos < queue.items.len) : (queue_pos += 1) { + const arg_jsvalue: JSValue = queue.items[queue_pos]; + const arg = arg_jsvalue.toBunString(globalThis); + if (group_count > 0) { + group_count -= 1; + } else { + index += 1; + } + + log(" (processing arg #{d}: \"{s}\")", .{ index, arg }); + + const token_subtype = classifyToken(arg, options); + log(" [Token #{d}] {s} ({s})", .{ index, @tagName(token_subtype), arg }); + + switch (token_subtype) { + // Check if `arg` is an options terminator. + // Guideline 10 in https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html + .option_terminator => { + // Everything after a bare '--' is considered a positional argument. + try tokens.append(Token{ .@"option-terminator" = .{ .index = index } }); + queue_pos += 1; + index += 1; + while (queue_pos < queue.items.len) : (queue_pos += 1) { + var value = queue.items[queue_pos].toBunString(globalThis); + try tokens.append(Token{ .positional = .{ .index = index, .value = value.toJSConst(globalThis) } }); + index += 1; + } + break; // Finished processing args, leave while loop. + }, + + // isLoneShortOption + .lone_short_option => { + // e.g. '-f' + const short_option = arg.charAtU8(1); + const option_idx = findOptionByShortName(short_option, options); + const option_type: OptionValueType = if (option_idx) |idx| options[idx].type else .boolean; + var value = JSValue.undefined; + var has_inline_value = true; + if (option_type == .string and queue_pos + 1 < queue.items.len) { + // e.g. '-f', "bar" + queue_pos += 1; + value = queue.items[queue_pos]; + has_inline_value = false; + log(" (lone_short_option consuming next token as value)", .{}); + } + try tokens.append(Token{ .option = .{ + .index = index, + .value = value, + .inline_value = has_inline_value, + .name = if (option_idx) |idx| options[idx].long_name.toJSConst(globalThis) else arg.substringWithLen(1, 2).toJSConst(globalThis), + .parse_type = .lone_short_option, + .raw = arg_jsvalue, + .option_idx = option_idx, + } }); + + if (!has_inline_value) { + index += 1; + } + }, + + // isShortOptionGroup + .short_option_group => { + // Expand -fXzy to -f -X -z -y + var num_short_options: usize = 0; + var string_option_index: ?usize = null; + const arg_len = arg.length(); + for (1..arg_len) |i| { + group_count += 1; + const short_option = arg.charAtU8(i); + const option_type: OptionValueType = if (findOptionByShortName(short_option, options)) |idx| options[idx].type else .boolean; + if (option_type != .string or i == arg_len - 1) { + // Boolean option, or last short in group. Well formed. + num_short_options += 1; + } else { + // String option in middle. Yuck. + // Expand -abfFILE to -a -b -fFILE + string_option_index = i; + break; // finished short group + } + } + var num_args_to_enqueue: usize = num_short_options + if (string_option_index != null) @as(usize, 1) else @as(usize, 0); + _ = try queue.addManyAt(queue_pos + 1, num_args_to_enqueue); + if (num_short_options > 0) { + var buf: [2]u8 = undefined; + buf[0] = '-'; + for (0..num_short_options) |i| { + buf[1] = arg.charAtU8(1 + i); + queue.items[queue_pos + 1 + i] = String.init(&buf).toJSConst(globalThis); + log(" ((enqueued: \"{s}\"))", .{String.init(&buf)}); + } + } + if (string_option_index) |i| { + const new_arg = try String.createFromConcat(globalThis.allocator(), &[_]String{ String.static("-"), arg.substring(i) }); + defer new_arg.deref(); + queue.items[queue_pos + 1 + num_short_options] = new_arg.toJSConst(globalThis); + log(" ((enqueued: \"{s}\"))", .{new_arg}); + } + }, + + .short_option_and_value => { + // e.g. -fFILE + const short_option = arg.charAtU8(1); + const option_idx = findOptionByShortName(short_option, options); + const value = arg.substring(2); + + try tokens.append(Token{ .option = .{ + .index = index, + .value = value.toJSConst(globalThis), + .inline_value = true, + .name = if (option_idx) |idx| options[idx].long_name.toJSConst(globalThis) else arg.substringWithLen(1, 2).toJSConst(globalThis), + .parse_type = .short_option_and_value, + .raw = arg_jsvalue, + .option_idx = option_idx, + } }); + }, + + .lone_long_option => { + // e.g. '--foo' + var long_option = arg.substring(2); + var value: ?JSValue = null; + var has_inline_value = true; + var option_idx = findOptionByLongName(long_option, options); + const option_type: OptionValueType = if (option_idx) |idx| options[idx].type else .boolean; + if (option_type == .string and queue_pos + 1 < queue.items.len) { + // e.g. '--foo', "bar" + queue_pos += 1; + value = queue.items[queue_pos]; + has_inline_value = false; + log(" (consuming next as value)", .{}); + } + try tokens.append(Token{ .option = .{ + .index = index, + .value = value orelse JSValue.jsUndefined(), + .inline_value = has_inline_value, + .name = long_option.toJSConst(globalThis), + .parse_type = .lone_long_option, + .raw = arg_jsvalue, + .option_idx = option_idx, + } }); + if (value != null) index += 1; + }, + + .long_option_and_value => { + // e.g. --foo=barconst + const equal_index = arg.indexOfCharU8('='); + const long_option = arg.substringWithLen(2, equal_index.?); + const value = arg.substring(equal_index.? + 1); + + try tokens.append(Token{ .option = .{ + .index = index, + .value = value.toJSConst(globalThis), + .inline_value = true, + .name = long_option.toJSConst(globalThis), + .parse_type = .long_option_and_value, + .raw = arg_jsvalue, + .option_idx = findOptionByLongName(long_option, options), + } }); + }, + + .positional => { + try tokens.append(Token{ .positional = .{ .index = index, .value = arg.toJSConst(globalThis) } }); + }, + } + } +} + +/// Create the parseArgs result "tokens" field +/// This field is opt-in, and people usually don't ask for it, +/// so only create the js values if they are needed +pub fn createOutputTokensArray(globalThis: *JSGlobalObject, tokens: []const Token) !JSValue { + const kinds_count = @typeInfo(TokenKind).Enum.fields.len; + var kinds_jsvalues: [kinds_count]?JSValue = [_]?JSValue{null} ** kinds_count; + + var result = JSC.JSValue.createEmptyArray(globalThis, tokens.len); + for (tokens, 0..) |token_generic, i| { + const obj_fields_count: usize = switch (token_generic) { + .option => |token| if (token.value.isUndefined()) 4 else 6, + .positional => 3, + .@"option-terminator" => 2, + }; + + // reuse JSValue for the kind names: "positional", "option", "option-terminator" + var kind_idx = @intFromEnum(token_generic); + var kind_jsvalue = kinds_jsvalues[kind_idx] orelse kindval: { + var val = String.static(@as(string, @tagName(token_generic))).toJSConst(globalThis); + kinds_jsvalues[kind_idx] = val; + break :kindval val; + }; + + var obj = JSValue.createEmptyObject(globalThis, obj_fields_count); + obj.put(globalThis, ZigString.static("kind"), kind_jsvalue); + switch (token_generic) { + .option => |token| { + obj.put(globalThis, ZigString.static("index"), JSValue.jsNumberFromInt32(token.index)); + obj.put(globalThis, ZigString.static("name"), token.name); + obj.put(globalThis, ZigString.static("rawName"), try token.makeRawNameJSValue(globalThis)); + + // only for boolean options, it is "undefined" + obj.put(globalThis, ZigString.static("value"), token.value); + obj.put(globalThis, ZigString.static("inlineValue"), if (token.value.isUndefined()) JSValue.undefined else JSValue.jsBoolean(token.inline_value)); + }, + .positional => |token| { + obj.put(globalThis, ZigString.static("index"), JSValue.jsNumberFromInt32(token.index)); + obj.put(globalThis, ZigString.static("value"), token.value); + }, + .@"option-terminator" => |token| { + obj.put(globalThis, ZigString.static("index"), JSValue.jsNumberFromInt32(token.index)); + }, + } + result.putIndex(globalThis, @intCast(i), obj); + } + return result; +} + +pub fn parseArgs(globalThis: *JSGlobalObject, config_obj: JSValue) !JSValue { + // + // Phase 0: parse the config object + // + + const config = if (config_obj.isUndefinedOrNull()) null else config_obj; + if (config) |c| { + try validateObject(globalThis, c, "config", .{}, .{}); + } + + // Phase 0.A: Get and validate type of input args + var args: ArgsSlice = undefined; + const config_args_or_null = if (config) |c| c.getOwn(globalThis, "args") else null; + if (config_args_or_null) |config_args| { + try validateArray(globalThis, config_args, "args", .{}, null); + args = .{ + .array = config_args, + .start = 0, + .end = @intCast(config_args.getLength(globalThis)), + }; + } else { + args = try getDefaultArgs(globalThis); + } + + // Phase 0.B: Parse and validate config + + const config_strict: JSValue = (if (config) |c| c.getOwn(globalThis, "strict") else null) orelse JSValue.jsBoolean(true); + const config_allow_positionals: ?JSValue = if (config) |c| c.getOwn(globalThis, "allowPositionals") else null; + const config_return_tokens: JSValue = (if (config) |c| c.getOwn(globalThis, "tokens") else null) orelse JSValue.jsBoolean(false); + const config_options_obj: ?JSValue = if (config) |c| c.getOwn(globalThis, "options") else null; + + const strict = try validateBoolean(globalThis, config_strict, "strict", .{}); + + var allow_positionals = !strict; + if (config_allow_positionals) |config_allow_positionals_value| { + allow_positionals = try validateBoolean(globalThis, config_allow_positionals_value, "allowPositionals", .{}); + } + + const return_tokens = try validateBoolean(globalThis, config_return_tokens, "tokens", .{}); + + // Phase 0.C: Parse the options definitions + + var options_defs_allocator = std.heap.stackFallback(2048, globalThis.allocator()); + var option_defs = std.ArrayList(OptionDefinition).init(options_defs_allocator.get()); + defer option_defs.deinit(); + + if (config_options_obj) |options_obj| { + try parseOptionDefinitions(globalThis, options_obj, &option_defs); + } + + // + // Phase 1: tokenize the args string-array + // + log("Phase 1: tokenize args (args.len={d})", .{args.end - args.start}); + + var tokens_allocator = std.heap.stackFallback(32 * @sizeOf(Token), globalThis.allocator()); + var tokens = try std.ArrayList(Token).initCapacity(tokens_allocator.get(), args.end - args.start); + defer tokens.deinit(); + + try tokenizeArgs(globalThis, args, option_defs.items, &tokens); + + // + // Phase 2: process tokens into parsed option values and positionals + // + log("Phase 2: parse options from tokens (tokens.len={d})", .{tokens.items.len}); + + // note that "values" needs to have a null prototype instead of Object, to avoid issues such as "values.toString"` being defined + var result_values = JSValue.constructEmptyObject(globalThis, null, 0); + var result_positionals = JSC.JSValue.createEmptyArray(globalThis, 0); + var result_positionals_len: u32 = 0; + for (tokens.items) |t| { + switch (t) { + .option => |token| { + if (strict) { + try checkOptionUsage(globalThis, option_defs.items, allow_positionals, token); + try checkOptionLikeValue(globalThis, token); + } + storeOption(globalThis, token.name, token.value, token.option_idx, option_defs.items, result_values); + }, + .positional => |token| { + if (!allow_positionals) { + const err = JSC.toTypeError( + JSC.Node.ErrorCode.ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL, + "Unexpected argument '{s}'. This command does not take positional arguments", + .{token.value.toBunString(globalThis)}, + globalThis, + ); + globalThis.vm().throwError(globalThis, err); + return error.ParseError; + } + result_positionals.putIndex(globalThis, result_positionals_len, token.value); + result_positionals_len += 1; + }, + else => {}, + } + } + + // + // Phase 3: fill in default values for missing args + // + log("Phase 3: fill defaults", .{}); + + for (option_defs.items) |option| { + if (option.default_value) |default_value| { + if (!option.long_name.eqlComptime("__proto__")) { + if (result_values.getOwn(globalThis, option.long_name) == null) { + log(" Setting \"{}\" to default value", .{option.long_name}); + result_values.put(globalThis, &option.long_name.toZigString(), default_value); + } + } + } + } + + // + // Phase 4: build the resulting object: `{ values: [...], positionals: [...], tokens?: [...] }` + // + log("Phase 4: Build result object", .{}); + + var result = JSValue.createEmptyObject(globalThis, if (return_tokens) 3 else 2); + if (return_tokens) { + const result_tokens = try createOutputTokensArray(globalThis, tokens.items); + result.put(globalThis, ZigString.static("tokens"), result_tokens); + } + result.put(globalThis, ZigString.static("values"), result_values); + result.put(globalThis, ZigString.static("positionals"), result_positionals); + return result; +} diff --git a/src/bun.js/node/util/parse_args_utils.zig b/src/bun.js/node/util/parse_args_utils.zig new file mode 100644 index 0000000000..d43ab53a49 --- /dev/null +++ b/src/bun.js/node/util/parse_args_utils.zig @@ -0,0 +1,447 @@ +const std = @import("std"); +const testing = std.testing; +const String = if (@import("builtin").is_test) TestString else @import("root").bun.String; +const JSValue = if (@import("builtin").is_test) usize else @import("root").bun.JSC.JSValue; + +pub const OptionValueType = enum { boolean, string }; + +/// Metadata of an option known to the args parser, +/// i.e. the values passed to `parseArgs(..., { options: })` +pub const OptionDefinition = struct { + long_name: String, + short_name: ?u8 = null, + type: OptionValueType = .boolean, + multiple: bool = false, + default_value: ?JSValue = null, +}; + +pub const TokenSubtype = enum { + /// '--' + option_terminator, + /// e.g. '-f' + lone_short_option, + /// e.g. '-fXzy' + short_option_group, + /// e.g. '-fFILE' + short_option_and_value, + /// e.g. '--foo' + lone_long_option, + /// e.g. '--foo=barconst' + long_option_and_value, + + positional, +}; + +pub inline fn classifyToken(arg: String, options: []const OptionDefinition) TokenSubtype { + const len = arg.length(); + + if (len == 2) { + if (arg.hasPrefixComptime("-")) { + return if (arg.hasPrefixComptime("--")) .option_terminator else .lone_short_option; + } + } else if (len > 2) { + if (arg.hasPrefixComptime("--")) { + return if ((arg.indexOfCharU8('=') orelse 0) >= 3) .long_option_and_value else .lone_long_option; + } else if (arg.hasPrefixComptime("-")) { + const first_letter: u8 = arg.charAtU8(1); + const option_idx = findOptionByShortName(first_letter, options); + if (option_idx) |i| { + return if (options[i].type == .string) .short_option_and_value else return .short_option_group; + } else { + return .short_option_group; + } + } + } + + return .positional; +} + +/// Detect whether there is possible confusion and user may have omitted +/// the option argument, like `--port --verbose` when `port` of type:string. +/// In strict mode we throw errors if value is option-like. +pub fn isOptionLikeValue(value: String) bool { + return value.length() > 1 and value.hasPrefixComptime("-"); +} + +/// Find the long option associated with a short option. Looks for a configured +/// `short` and returns the short option itself if a long option is not found. +/// Example: +/// findOptionByShortName('a', {}) // returns 'a' +/// findOptionByShortName('b', { +/// options: { bar: { short: 'b' } } +/// }) // returns "bar" +pub fn findOptionByShortName(short_name: u8, options: []const OptionDefinition) ?usize { + var long_option_index: ?usize = null; + for (options, 0..) |option, i| { + if (option.short_name == short_name) { + return i; + } + if (option.long_name.length() == 1 and option.long_name.charAtU8(0) == short_name) { + long_option_index = i; + } + } + return long_option_index; +} + +// +// TESTS +// + +var no_options: []const OptionDefinition = &[_]OptionDefinition{}; + +/// Used only for tests, as lightweight substitute for bun.String +const TestString = struct { + str: []const u8, + fn length(this: TestString) usize { + return this.str.len; + } + fn hasPrefixComptime(this: TestString, comptime prefix: []const u8) bool { + return std.mem.startsWith(u8, this.str, prefix); + } + fn charAtU8(this: TestString, i: usize) u8 { + return this.str[i]; + } + fn indexOfCharU8(this: TestString, chr: u8) ?usize { + return std.mem.indexOfScalar(u8, this.str, chr); + } +}; +fn s(str: []const u8) TestString { + return TestString{ .str = str }; +} + +// +// misc +// + +test "classifyToken: is option terminator" { + try testing.expectEqual(classifyToken(s("--"), no_options), .option_terminator); +} + +test "classifyToken: is positional" { + try testing.expectEqual(classifyToken(s("abc"), no_options), .positional); +} + +// +// isLoneLongOption +// + +pub fn isLoneLongOption(value: String) bool { + return classifyToken(value, no_options) == .lone_long_option; +} + +test "isLoneLongOption: when passed short option then returns false" { + try testing.expectEqual(isLoneLongOption(s("-s")), false); +} + +test "isLoneLongOption: when passed short option group then returns false" { + try testing.expectEqual(isLoneLongOption(s("-abc")), false); +} + +test "isLoneLongOption: when passed lone long option then returns true" { + try testing.expectEqual(isLoneLongOption(s("--foo")), true); +} + +test "isLoneLongOption: when passed single character long option then returns true" { + try testing.expectEqual(isLoneLongOption(s("--f")), true); +} + +test "isLoneLongOption: when passed long option and value then returns false" { + try testing.expectEqual(isLoneLongOption(s("--foo=bar")), false); +} + +test "isLoneLongOption: when passed empty string then returns false" { + try testing.expectEqual(isLoneLongOption(s("")), false); +} + +test "isLoneLongOption: when passed plain text then returns false" { + try testing.expectEqual(isLoneLongOption(s("foo")), false); +} + +test "isLoneLongOption: when passed single dash then returns false" { + try testing.expectEqual(isLoneLongOption(s("-")), false); +} + +test "isLoneLongOption: when passed double dash then returns false" { + try testing.expectEqual(isLoneLongOption(s("--")), false); +} + +// This is a bit bogus, but simple consistent behaviour: long option follows double dash. +test "isLoneLongOption: when passed arg starting with triple dash then returns true" { + try testing.expectEqual(isLoneLongOption(s("---foo")), true); +} + +// This is a bit bogus, but simple consistent behaviour: long option follows double dash. +test "isLoneLongOption: when passed '--=' then returns true" { + try testing.expectEqual(isLoneLongOption(s("--=")), true); +} + +// +// isLoneShortOption +// + +pub fn isLoneShortOption(value: String) bool { + return classifyToken(value, no_options) == .lone_short_option; +} + +test "isLoneShortOption: when passed short option then returns true" { + try testing.expectEqual(isLoneShortOption(s("-s")), true); +} + +test "isLoneShortOption: when passed short option group (or might be short and value) then returns false" { + try testing.expectEqual(isLoneShortOption(s("-abc")), false); +} + +test "isLoneShortOption: when passed long option then returns false" { + try testing.expectEqual(isLoneShortOption(s("--foo")), false); +} + +test "isLoneShortOption: when passed long option with value then returns false" { + try testing.expectEqual(isLoneShortOption(s("--foo=bar")), false); +} + +test "isLoneShortOption: when passed empty string then returns false" { + try testing.expectEqual(isLoneShortOption(s("")), false); +} + +test "isLoneShortOption: when passed plain text then returns false" { + try testing.expectEqual(isLoneShortOption(s("foo")), false); +} + +test "isLoneShortOption: when passed single dash then returns false" { + try testing.expectEqual(isLoneShortOption(s("-")), false); +} + +test "isLoneShortOption: when passed double dash then returns false" { + try testing.expectEqual(isLoneShortOption(s("--")), false); +} + +// +// isLongOptionAndValue +// + +pub fn isLongOptionAndValue(value: String) bool { + return classifyToken(value, no_options) == .long_option_and_value; +} + +test "isLongOptionAndValue: when passed short option then returns false" { + try testing.expectEqual(isLongOptionAndValue(s("-s")), false); +} + +test "isLongOptionAndValue: when passed short option group then returns false" { + try testing.expectEqual(isLongOptionAndValue(s("-abc")), false); +} + +test "isLongOptionAndValue: when passed lone long option then returns false" { + try testing.expectEqual(isLongOptionAndValue(s("--foo")), false); +} + +test "isLongOptionAndValue: when passed long option and value then returns true" { + try testing.expectEqual(isLongOptionAndValue(s("--foo=bar")), true); +} + +test "isLongOptionAndValue: when passed single character long option and value then returns true" { + try testing.expectEqual(isLongOptionAndValue(s("--f=bar")), true); +} + +test "isLongOptionAndValue: when passed empty string then returns false" { + try testing.expectEqual(isLongOptionAndValue(s("")), false); +} + +test "isLongOptionAndValue: when passed plain text then returns false" { + try testing.expectEqual(isLongOptionAndValue(s("foo")), false); +} + +test "isLongOptionAndValue: when passed single dash then returns false" { + try testing.expectEqual(isLongOptionAndValue(s("-")), false); +} + +test "isLongOptionAndValue: when passed double dash then returns false" { + try testing.expectEqual(isLongOptionAndValue(s("--")), false); +} + +// This is a bit bogus, but simple consistent behaviour: long option follows double dash. +test "isLongOptionAndValue: when passed arg starting with triple dash and value then returns true" { + try testing.expectEqual(isLongOptionAndValue(s("---foo=bar")), true); +} + +// This is a bit bogus, but simple consistent behaviour: long option follows double dash. +test "isLongOptionAndValue: when passed '--=' then returns false" { + try testing.expectEqual(isLongOptionAndValue(s("--=")), false); +} + +// +// isOptionLikeValue +// +// Basically rejecting values starting with a dash, but run through the interesting possibilities. + +test "isOptionLikeValue: when passed plain text then returns false" { + try testing.expectEqual(isOptionLikeValue(s("abc")), false); +} + +//test "isOptionLikeValue: when passed digits then returns false" { +// try testing.expectEqual(isOptionLikeValue(123), false); +//} + +test "isOptionLikeValue: when passed empty string then returns false" { + try testing.expectEqual(isOptionLikeValue(s("")), false); +} + +// Special case, used as stdin/stdout et al and not reason to reject +test "isOptionLikeValue: when passed dash then returns false" { + try testing.expectEqual(isOptionLikeValue(s("-")), false); +} + +test "isOptionLikeValue: when passed -- then returns true" { + // Not strictly option-like, but is supect + try testing.expectEqual(isOptionLikeValue(s("--")), true); +} + +// Supporting undefined so can pass element off end of array without checking +//test "isOptionLikeValue: when passed undefined then returns false" { +// try testing.expectEqual(isOptionLikeValue(undefined), false); +//} + +test "isOptionLikeValue: when passed short option then returns true" { + try testing.expectEqual(isOptionLikeValue(s("-a")), true); +} + +test "isOptionLikeValue: when passed short option digit then returns true" { + try testing.expectEqual(isOptionLikeValue(s("-1")), true); +} + +test "isOptionLikeValue: when passed negative number then returns true" { + try testing.expectEqual(isOptionLikeValue(s("-123")), true); +} + +test "isOptionLikeValue: when passed short option group of short option with value then returns true" { + try testing.expectEqual(isOptionLikeValue(s("-abd")), true); +} + +test "isOptionLikeValue: when passed long option then returns true" { + try testing.expectEqual(isOptionLikeValue(s("--foo")), true); +} + +test "isOptionLikeValue: when passed long option with value then returns true" { + try testing.expectEqual(isOptionLikeValue(s("--foo=bar")), true); +} + +// +// isShortOptionAndValue +// + +pub fn isShortOptionAndValue(value: String, options: []const OptionDefinition) bool { + return classifyToken(value, options) == .short_option_and_value; +} + +test "isShortOptionAndValue: when passed lone short option then returns false" { + try testing.expectEqual(isShortOptionAndValue(s("-s"), no_options), false); +} + +test "isShortOptionAndValue: when passed group with leading zero-config boolean then returns false" { + try testing.expectEqual(isShortOptionAndValue(s("-ab"), no_options), false); +} + +test "isShortOptionAndValue: when passed group with leading configured implicit boolean then returns false" { + const options = &[_]OptionDefinition{.{ .long_name = s("aaa"), .short_name = 'a' }}; + try testing.expectEqual(isShortOptionAndValue(s("-ab"), options), false); +} + +test "isShortOptionAndValue: when passed group with leading configured explicit boolean then returns false" { + const options = &[_]OptionDefinition{.{ .long_name = s("aaa"), .short_name = 'a', .type = .boolean }}; + try testing.expectEqual(isShortOptionAndValue(s("-ab"), options), false); +} + +test "isShortOptionAndValue: when passed group with leading configured string then returns true" { + const options = &[_]OptionDefinition{.{ .long_name = s("aaa"), .short_name = 'a', .type = .string }}; + try testing.expectEqual(isShortOptionAndValue(s("-ab"), options), true); +} + +test "isShortOptionAndValue: when passed long option then returns false" { + try testing.expectEqual(isShortOptionAndValue(s("--foo"), no_options), false); +} + +test "isShortOptionAndValue: when passed long option with value then returns false" { + try testing.expectEqual(isShortOptionAndValue(s("--foo=bar"), no_options), false); +} + +test "isShortOptionAndValue: when passed empty string then returns false" { + try testing.expectEqual(isShortOptionAndValue(s(""), no_options), false); +} + +test "isShortOptionAndValue: when passed plain text then returns false" { + try testing.expectEqual(isShortOptionAndValue(s("foo"), no_options), false); +} + +test "isShortOptionAndValue: when passed single dash then returns false" { + try testing.expectEqual(isShortOptionAndValue(s("-"), no_options), false); +} + +test "isShortOptionAndValue: when passed double dash then returns false" { + try testing.expectEqual(isShortOptionAndValue(s("--"), no_options), false); +} + +// +// isShortOptionGroup +// + +pub fn isShortOptionGroup(value: String, options: []const OptionDefinition) bool { + return classifyToken(value, options) == .short_option_group; +} + +test "isShortOptionGroup: when passed lone short option then returns false" { + try testing.expectEqual(isShortOptionGroup(s("-s"), no_options), false); +} + +test "isShortOptionGroup: when passed group with leading zero-config boolean then returns true" { + try testing.expectEqual(isShortOptionGroup(s("-ab"), no_options), true); +} + +test "isShortOptionGroup: when passed group with leading configured implicit boolean then returns true" { + const options = &[_]OptionDefinition{.{ .long_name = s("aaa"), .short_name = 'a' }}; + try testing.expectEqual(isShortOptionGroup(s("-ab"), options), true); +} + +test "isShortOptionGroup: when passed group with leading configured explicit boolean then returns true" { + const options = &[_]OptionDefinition{.{ .long_name = s("aaa"), .short_name = 'a', .type = .boolean }}; + try testing.expectEqual(isShortOptionGroup(s("-ab"), options), true); +} + +test "isShortOptionGroup: when passed group with leading configured string then returns false" { + const options = &[_]OptionDefinition{.{ .long_name = s("aaa"), .short_name = 'a', .type = .string }}; + try testing.expectEqual(isShortOptionGroup(s("-ab"), options), false); +} + +test "isShortOptionGroup: when passed group with trailing configured string then returns true" { + const options = &[_]OptionDefinition{.{ .long_name = s("bbb"), .short_name = 'b', .type = .string }}; + try testing.expectEqual(isShortOptionGroup(s("-ab"), options), true); +} + +// This one is dubious, but leave it to caller to handle. +test "isShortOptionGroup: when passed group with middle configured string then returns true" { + const options = &[_]OptionDefinition{.{ .long_name = s("bbb"), .short_name = 'b', .type = .string }}; + try testing.expectEqual(isShortOptionGroup(s("-abc"), options), true); +} + +test "isShortOptionGroup: when passed long option then returns false" { + try testing.expectEqual(isShortOptionGroup(s("--foo"), no_options), false); +} + +test "isShortOptionGroup: when passed long option with value then returns false" { + try testing.expectEqual(isShortOptionGroup(s("--foo=bar"), no_options), false); +} + +test "isShortOptionGroup: when passed empty string then returns false" { + try testing.expectEqual(isShortOptionGroup(s(""), no_options), false); +} + +test "isShortOptionGroup: when passed plain text then returns false" { + try testing.expectEqual(isShortOptionGroup(s("foo"), no_options), false); +} + +test "isShortOptionGroup: when passed single dash then returns false" { + try testing.expectEqual(isShortOptionGroup(s("-"), no_options), false); +} + +test "isShortOptionGroup: when passed double dash then returns false" { + try testing.expectEqual(isShortOptionGroup(s("--"), no_options), false); +} diff --git a/src/bun.js/node/util/validators.zig b/src/bun.js/node/util/validators.zig new file mode 100644 index 0000000000..f46698a6bc --- /dev/null +++ b/src/bun.js/node/util/validators.zig @@ -0,0 +1,249 @@ +const std = @import("std"); +const bun = @import("root").bun; +const string = bun.string; +const JSC = @import("root").bun.JSC; +const JSValue = JSC.JSValue; +const JSGlobalObject = JSC.JSGlobalObject; +const ZigString = JSC.ZigString; + +pub fn getTypeName(globalObject: *JSGlobalObject, value: JSValue) ZigString { + var js_type = value.jsType(); + if (js_type.isArray()) { + return ZigString.static("array").*; + } + return value.jsTypeString(globalObject).getZigString(globalObject); +} + +pub fn throwErrInvalidArgValue( + globalThis: *JSGlobalObject, + comptime fmt: string, + args: anytype, +) !void { + @setCold(true); + const err = JSC.toTypeError(JSC.Node.ErrorCode.ERR_INVALID_ARG_VALUE, fmt, args, globalThis); + globalThis.vm().throwError(globalThis, err); + return error.InvalidArgument; +} + +pub fn throwErrInvalidArgTypeWithMessage( + globalThis: *JSGlobalObject, + comptime fmt: string, + args: anytype, +) !void { + @setCold(true); + const err = JSC.toTypeError(JSC.Node.ErrorCode.ERR_INVALID_ARG_TYPE, fmt, args, globalThis); + globalThis.vm().throwError(globalThis, err); + return error.InvalidArgument; +} + +pub fn throwErrInvalidArgType( + globalThis: *JSGlobalObject, + comptime name_fmt: string, + name_args: anytype, + comptime expected_type: []const u8, + value: JSValue, +) !void { + @setCold(true); + const actual_type = getTypeName(globalThis, value); + try throwErrInvalidArgTypeWithMessage(globalThis, "\"" ++ name_fmt ++ "\" property must be of type {s}, got {s}", name_args ++ .{ expected_type, actual_type }); +} + +pub fn throwRangeError( + globalThis: *JSGlobalObject, + comptime fmt: string, + args: anytype, +) !void { + @setCold(true); + const err = globalThis.createRangeErrorInstanceWithCode(JSC.Node.ErrorCode.ERR_OUT_OF_RANGE, fmt, args); + globalThis.vm().throwError(globalThis, err); + return error.InvalidArgument; +} + +/// -(2^53 - 1) +pub const NUMBER__MIN_SAFE_INTEGER: i64 = -9007199254740991; +/// (2^53 – 1) +pub const NUMBER__MAX_SAFE_INTEGER: i64 = 9007199254740991; + +pub fn validateInteger(globalThis: *JSGlobalObject, value: JSValue, comptime name_fmt: string, name_args: anytype, min_value: ?i64, max_value: ?i64) !i64 { + const min = min_value orelse NUMBER__MIN_SAFE_INTEGER; + const max = max_value orelse NUMBER__MAX_SAFE_INTEGER; + + if (!value.isNumber()) + try throwErrInvalidArgType(globalThis, name_fmt, name_args, "number", value); + if (!value.isAnyInt()) { + try throwRangeError(globalThis, "The value of \"" ++ name_fmt ++ "\" is out of range. It must be an integer. Received {s}", name_args ++ .{value}); + } + + const num = value.asInt52(); + if (num < min or num > max) { + try throwRangeError(globalThis, "The value of \"" ++ name_fmt ++ "\" is out of range. It must be >= {d} and <= {d}. Received {s}", name_args ++ .{ min, max, value }); + } + return num; +} + +pub fn validateInt32(globalThis: *JSGlobalObject, value: JSValue, comptime name_fmt: string, name_args: anytype, min_value: ?i32, max_value: ?i32) !i32 { + const min = min_value orelse std.math.minInt(i32); + const max = max_value orelse std.math.maxInt(i32); + // The defaults for min and max correspond to the limits of 32-bit integers. + if (!value.isNumber()) { + try throwErrInvalidArgType(globalThis, name_fmt, name_args, "number", value); + } + if (!value.isInt32()) { + try throwRangeError(globalThis, "The value of \"" ++ name_fmt ++ "\" is out of range. It must be an integer. Received {s}", name_args ++ .{value}); + } + const num = value.asInt32(); + if (num < min or num > max) { + try throwRangeError(globalThis, "The value of \"" ++ name_fmt ++ "\" is out of range. It must be >= {d} and <= {d}. Received {s}", name_args ++ .{ min, max, value }); + } + return num; +} + +pub fn validateUint32(globalThis: *JSGlobalObject, value: JSValue, comptime name_fmt: string, name_args: anytype, greater_than_zero: bool) !u32 { + if (!value.isNumber()) { + try throwErrInvalidArgType(globalThis, name_fmt, name_args, "number", value); + } + if (!value.isAnyInt()) { + try throwRangeError(globalThis, "The value of \"" ++ name_fmt ++ "\" is out of range. It must be an integer. Received {s}", name_args ++ .{value}); + } + const num: i64 = value.asInt52(); + const min: i64 = if (greater_than_zero) 1 else 0; + const max: i64 = @intCast(std.math.maxInt(u32)); + if (num < min or num > max) { + try throwRangeError(globalThis, "The value of \"" ++ name_fmt ++ "\" is out of range. It must be >= {d} and <= {d}. Received {s}", name_args ++ .{ min, max, value }); + } + return @truncate(num); +} + +pub fn validateString(globalThis: *JSGlobalObject, value: JSValue, comptime name_fmt: string, name_args: anytype) !void { + if (!value.isString()) + try throwErrInvalidArgType(globalThis, name_fmt, name_args, "string", value); +} + +pub fn validateNumber(globalThis: *JSGlobalObject, value: JSValue, comptime name_fmt: string, name_args: anytype, min: ?f64, max: ?f64) !f64 { + if (!value.isNumber()) + try throwErrInvalidArgType(globalThis, name_fmt, name_args, "number", value); + + const num: f64 = value.asNumber(); + var valid = true; + if (min) |min_val| { + if (num < min_val) valid = false; + } + if (max) |max_val| { + if (num > max_val) valid = false; + } + if ((min != null or max != null) and std.math.isNan(num)) { + valid = false; + } + if (!valid) { + if (min != null and max != null) { + try throwRangeError(globalThis, "The value of \"" ++ name_fmt ++ "\" is out of range. It must be >= {d} and <= {d}. Received {s}", name_args ++ .{ min, max, value }); + } else if (min != null) { + try throwRangeError(globalThis, "The value of \"" ++ name_fmt ++ "\" is out of range. It must be >= {d}. Received {s}", name_args ++ .{ max, value }); + } else { + try throwRangeError(globalThis, "The value of \"" ++ name_fmt ++ "\" is out of range. It must and <= {d}. Received {s}", name_args ++ .{ max, value }); + } + } + return num; +} + +pub fn validateBoolean(globalThis: *JSGlobalObject, value: JSValue, comptime name_fmt: string, name_args: anytype) !bool { + if (!value.isBoolean()) + try throwErrInvalidArgType(globalThis, name_fmt, name_args, "boolean", value); + return value.asBoolean(); +} + +pub const ValidateObjectOptions = packed struct { + allow_nullable: bool = false, + allow_array: bool = false, + allow_function: bool = false, +}; + +pub fn validateObject(globalThis: *JSGlobalObject, value: JSValue, comptime name_fmt: string, name_args: anytype, comptime options: ValidateObjectOptions) !void { + if (comptime !options.allow_nullable and !options.allow_array and !options.allow_function) { + if (value.isNull() or value.jsType().isArray()) { + try throwErrInvalidArgType(globalThis, name_fmt, name_args, "object", value); + } + + if (!value.isObject()) { + try throwErrInvalidArgType(globalThis, name_fmt, name_args, "object", value); + } + } else { + if (!options.allow_nullable and value.isNull()) { + try throwErrInvalidArgType(globalThis, name_fmt, name_args, "object", value); + } + + if (!options.allow_array and value.jsType().isArray()) { + try throwErrInvalidArgType(globalThis, name_fmt, name_args, "object", value); + } + + if (!value.isObject() and (!options.allow_function or !value.jsType().isFunction())) { + try throwErrInvalidArgType(globalThis, name_fmt, name_args, "object", value); + } + } +} + +pub fn validateArray(globalThis: *JSGlobalObject, value: JSValue, comptime name_fmt: string, name_args: anytype, comptime min_length: ?i32) !void { + if (!value.jsType().isArray()) { + const actual_type = getTypeName(globalThis, value); + try throwErrInvalidArgTypeWithMessage(globalThis, "\"" ++ name_fmt ++ "\" property must be an instance of Array, got {s}", name_args ++ .{actual_type}); + } + if (comptime min_length != null) { + if (value.getLength(globalThis) < min_length) { + try throwErrInvalidArgValue(globalThis, name_fmt ++ " must be longer than {d}", name_args ++ .{min_length}); + } + } +} + +pub fn validateStringArray(globalThis: *JSGlobalObject, value: JSValue, comptime name_fmt: string, name_args: anytype) !usize { + try validateArray(globalThis, value, name_fmt, name_args, null); + var i: usize = 0; + var iter = value.arrayIterator(globalThis); + while (iter.next()) |item| { + if (!item.isString()) { + try throwErrInvalidArgType(globalThis, name_fmt ++ "[{d}]", name_args ++ .{i}, "string", value); + } + i += 1; + } + return i; +} + +pub fn validateBooleanArray(globalThis: *JSGlobalObject, value: JSValue, comptime name_fmt: string, name_args: anytype) !usize { + try validateArray(globalThis, value, name_fmt, name_args, null); + var i: usize = 0; + var iter = value.arrayIterator(globalThis); + while (iter.next()) |item| { + if (!item.isBoolean()) { + try throwErrInvalidArgType(globalThis, name_fmt ++ "[{d}]", name_args ++ .{i}, "boolean", value); + } + i += 1; + } + return i; +} + +pub fn validateFunction(globalThis: *JSGlobalObject, value: JSValue, comptime name_fmt: string, name_args: anytype) !void { + if (!value.jsType().isFunction()) + try throwErrInvalidArgType(globalThis, name_fmt, name_args, "function", value); +} + +pub fn validateUndefined(globalThis: *JSGlobalObject, value: JSValue, comptime name_fmt: string, name_args: anytype) !void { + if (!value.isUndefined()) + try throwErrInvalidArgType(globalThis, name_fmt, name_args, "undefined", value); +} + +pub fn validateStringEnum(comptime T: type, globalThis: *JSGlobalObject, value: JSValue, comptime name_fmt: string, name_args: anytype) !T { + const str = value.toBunString(globalThis); + inline for (@typeInfo(T).Enum.fields) |enum_field| { + if (str.eqlComptime(enum_field.name)) + return @field(T, enum_field.name); + } + + const values_info = comptime blk: { + var out: []const u8 = ""; + for (@typeInfo(T).Enum.fields, 0..) |enum_field, i| { + out = out ++ (if (i > 0) "|" else "") ++ enum_field.name; + } + break :blk out; + }; + try throwErrInvalidArgTypeWithMessage(globalThis, name_fmt ++ " must be one of: {s}", name_args ++ .{values_info}); + return error.InvalidArgument; +} diff --git a/src/js/node/util.js b/src/js/node/util.js index 2002eed252..6cea19d695 100644 --- a/src/js/node/util.js +++ b/src/js/node/util.js @@ -15,6 +15,7 @@ function isFunction(value) { const deepEquals = Bun.deepEquals; const isDeepStrictEqual = (a, b) => deepEquals(a, b, true); var getOwnPropertyDescriptors = Object.getOwnPropertyDescriptors; +const parseArgs = Bun.parseArgs; const inspect = utl.inspect; const formatWithOptions = utl.formatWithOptions; @@ -270,4 +271,5 @@ export default Object.assign(cjs_exports, { isDeepStrictEqual, TextDecoder, TextEncoder, + parseArgs, }); diff --git a/src/string.zig b/src/string.zig index d2dc561a40..33f20a61aa 100644 --- a/src/string.zig +++ b/src/string.zig @@ -653,8 +653,12 @@ pub const String = extern struct { return self.tag == .Empty; } - pub fn substring(self: String, offset: usize) String { - return String.init(self.toZigString().substring(offset)); + pub fn substring(self: String, start_index: usize) String { + return String.init(self.toZigString().substring(start_index)); + } + + pub fn substringWithLen(self: String, start_index: usize, end_index: usize) String { + return String.init(self.toZigString().substringWithLen(start_index, end_index)); } pub fn toUTF8(this: String, allocator: std.mem.Allocator) ZigString.Slice { @@ -731,6 +735,52 @@ pub const String = extern struct { }; } + pub fn charAt(this: String, index: usize) u16 { + if (comptime bun.Environment.allow_assert) { + std.debug.assert(index < this.length()); + } + return switch (this.tag) { + .WTFStringImpl => if (this.value.WTFStringImpl.is8Bit()) @intCast(this.value.WTFStringImpl.utf8Slice()[index]) else this.value.WTFStringImpl.utf16Slice()[index], + .ZigString, .StaticZigString => if (!this.value.ZigString.is16Bit()) @intCast(this.value.ZigString.slice()[index]) else this.value.ZigString.utf16Slice()[index], + else => 0, + }; + } + + pub fn charAtU8(this: String, index: usize) u8 { + if (comptime bun.Environment.allow_assert) { + std.debug.assert(index < this.length()); + } + return switch (this.tag) { + .WTFStringImpl => if (this.value.WTFStringImpl.is8Bit()) this.value.WTFStringImpl.utf8Slice()[index] else @truncate(this.value.WTFStringImpl.utf16Slice()[index]), + .ZigString, .StaticZigString => if (!this.value.ZigString.is16Bit()) this.value.ZigString.slice()[index] else @truncate(this.value.ZigString.utf16SliceAligned()[index]), + else => 0, + }; + } + + pub fn indexOfChar(this: String, chr: u16) ?usize { + switch (this.tag) { + .WTFStringImpl => { + if (this.value.WTFStringImpl.is8Bit()) { + return std.mem.indexOfScalar(u8, this.value.WTFStringImpl.utf8Slice(), @truncate(chr)); + } else { + return std.mem.indexOfScalar(u16, this.value.WTFStringImpl.utf16Slice(), chr); + } + }, + .ZigString, .StaticZigString => { + if (!this.value.ZigString.is16Bit()) { + return std.mem.indexOfScalar(u8, this.value.ZigString.slice(), @truncate(chr)); + } else { + return std.mem.indexOfScalar(u16, this.value.ZigString.utf16SliceAligned(), chr); + } + }, + else => return null, + } + } + + pub fn indexOfCharU8(this: String, chr: u8) ?usize { + return indexOfChar(this, @intCast(chr)); + } + pub fn indexOfComptimeWithCheckLen(this: String, comptime values: []const []const u8, comptime check_len: usize) ?usize { if (this.is8Bit()) { const bytes = this.byteSlice(); @@ -866,6 +916,47 @@ pub const String = extern struct { } extern fn JSC__createError(*JSC.JSGlobalObject, str: *String) JSC.JSValue; + + fn concat(comptime n: usize, allocator: std.mem.Allocator, strings: *const [n]String) !String { + var num_16bit: usize = 0; + inline for (strings) |str| { + if (!str.is8Bit()) num_16bit += 1; + } + + if (num_16bit == n) { + // all are 16bit + var slices: [n][]const u16 = undefined; + for (strings, 0..) |str, i| { + slices[i] = switch (str.tag) { + .WTFStringImpl => str.value.WTFStringImpl.utf16Slice(), + .ZigString, .StaticZigString => str.value.ZigString.utf16SliceAligned(), + else => &[_]u16{}, + }; + } + const result = try std.mem.concat(allocator, u16, &slices); + return init(ZigString.from16Slice(result)); + } else { + // either all 8bit, or mixed 8bit and 16bit + var slices_holded: [n]SliceWithUnderlyingString = undefined; + var slices: [n][]const u8 = undefined; + inline for (strings, 0..) |str, i| { + slices_holded[i] = str.toSlice(allocator); + slices[i] = slices_holded[i].slice(); + } + const result = try std.mem.concat(allocator, u8, &slices); + inline for (0..n) |i| { + slices_holded[i].deinit(); + } + return create(result); + } + } + + /// Creates a new String from a given tuple (of comptime-known size) of String. + /// + /// Note: the callee owns the resulting string and must call `.deref()` on it once done + pub inline fn createFromConcat(allocator: std.mem.Allocator, strings: anytype) !String { + return try concat(strings.len, allocator, strings); + } }; pub const SliceWithUnderlyingString = struct { diff --git a/test/js/node/util/parse_args/default-args.test.mjs b/test/js/node/util/parse_args/default-args.test.mjs new file mode 100644 index 0000000000..95865dd3b2 --- /dev/null +++ b/test/js/node/util/parse_args/default-args.test.mjs @@ -0,0 +1,80 @@ +import { spawn } from "bun"; +import { afterAll, beforeAll, expect, test, describe } from "bun:test"; +import { bunExe, bunEnv } from "harness"; +import fs from "fs/promises"; +import os from "os"; +import path from "path"; + +describe("parseArgs default args", () => { + let temp_dir; + + beforeAll(async () => { + temp_dir = await fs.realpath( + await fs.mkdtemp(path.join(os.tmpdir(), "bun-run.test." + Math.trunc(Math.random() * 9999999).toString(32))), + ); + await fs.writeFile( + path.join(temp_dir, "package.json"), + `{ + "scripts": { + "script-test": "file-test.js" + } + }`, + ); + await fs.writeFile( + path.join(temp_dir, "file-test.js"), + `console.log(JSON.stringify({ argv: process.argv, execArgv: process.execArgv, ...Bun.parseArgs({ strict: false }) }));`, + ); + }); + afterAll(async () => { + await fs.rm(temp_dir, { force: true, recursive: true }); + }); + + async function spawnBun(...args) { + const subprocess = spawn({ + cmd: [bunExe(), ...args], + cwd: temp_dir, + stdout: "pipe", + stderr: "pipe", + stdin: "pipe", + env: { + ...bunEnv, + }, + }); + subprocess.stdin.end(); + let exited = false; + let timer = setTimeout(() => { + if (!exited) { + subprocess.kill(); + } + }, 5000); + const exitCode = await subprocess.exited; + exited = true; + clearTimeout(timer); + const stdout = await new Response(subprocess.stdout).text(); + expect(exitCode).toBe(0); + return { stdout }; + } + + test.each([ + ["file-test.js --foo asdf", ["foo"], ["asdf"], []], // implicit run + ["run file-test.js --foo asdf", ["foo"], ["asdf"], []], // explicit run + ["--bun file-test.js --foo asdf", ["foo"], ["asdf"], ["--bun"]], // implicit run, with bun "--bun" arg (should not appear in argv) + ["run --bun file-test.js --foo asdf", ["foo"], ["asdf"], ["--bun"]], // explicit run, with bun "--bun" arg (after the run) + ["--bun run file-test.js --foo asdf", ["foo"], ["asdf"], ["--bun"]], // explicit run, with bun "--bun" arg (before the run) + ["--bun run --env-file='' file-test.js --foo asdf", ["foo"], ["asdf"], ["--bun", "--env-file=''"]], // explicit run, multiple bun args + ["run file-test.js --bun", ["bun"], [], []], // passing --bun only to the program + ["--bun run file-test.js --foo asdf -- --foo2 -- --foo3", ["foo"], ["asdf", "--foo2", "--", "--foo3"], ["--bun"]], + //[`--bun -e ${evalSrc} --foo asdf`, ["foo"], ["asdf"]], // eval seems to crash when triggered from tests + //[`--bun --eval ${evalSrc} --foo asdf`, ["foo"], ["asdf"]], + //[`--eval "require('./file-test.js')" -- --foo asdf -- --bar`, ["foo"], ["asdf"]], + ])('running "bun %s"', async (argline, valuesKeys, positionals, execArgv) => { + const result = await spawnBun(...argline.split(/\s+/)); + let output; + expect(() => (output = JSON.parse(result.stdout))).not.toThrow(); + expect(Object.keys(output?.values ?? {}).sort()).toEqual(valuesKeys.sort()); + expect(output?.positionals).toEqual(positionals); + if (execArgv) { + expect(output?.execArgv).toEqual(execArgv); + } + }); +}); diff --git a/test/js/node/util/parse_args/parse-args.test.mjs b/test/js/node/util/parse_args/parse-args.test.mjs new file mode 100644 index 0000000000..cba0e61035 --- /dev/null +++ b/test/js/node/util/parse_args/parse-args.test.mjs @@ -0,0 +1,974 @@ +import { test, expect } from "bun:test"; +import { parseArgs } from "node:util"; + +// Test file adapted from Node v21 + +const expectToThrowErrorMatching = (fn, errorPattern) => { + let error = undefined; + try { + fn(); + } catch (err) { + error = err; + } + expect(error).toMatchObject(errorPattern); +}; + +test("when short option used as flag then stored as flag", () => { + const args = ["-f"]; + const expected = { values: { __proto__: null, f: true }, positionals: [] }; + const result = parseArgs({ strict: false, args }); + expect(result).toEqual(expected); +}); + +test("when short option used as flag before positional then stored as flag and positional (and not value)", () => { + const args = ["-f", "bar"]; + const expected = { values: { __proto__: null, f: true }, positionals: ["bar"] }; + const result = parseArgs({ strict: false, args }); + expect(result).toEqual(expected); +}); + +test('when short option `type: "string"` used with value then stored as value', () => { + const args = ["-f", "bar"]; + const options = { f: { type: "string" } }; + const expected = { values: { __proto__: null, f: "bar" }, positionals: [] }; + const result = parseArgs({ args, options }); + expect(result).toEqual(expected); +}); + +test("when short option listed in short used as flag then long option stored as flag", () => { + const args = ["-f"]; + const options = { foo: { short: "f", type: "boolean" } }; + const expected = { values: { __proto__: null, foo: true }, positionals: [] }; + const result = parseArgs({ args, options }); + expect(result).toEqual(expected); +}); + +test( + 'when short option listed in short and long listed in `type: "string"` and ' + + "used with value then long option stored as value", + () => { + const args = ["-f", "bar"]; + const options = { foo: { short: "f", type: "string" } }; + const expected = { values: { __proto__: null, foo: "bar" }, positionals: [] }; + const result = parseArgs({ args, options }); + expect(result).toEqual(expected); + }, +); + +test('when short option `type: "string"` used without value then stored as flag', () => { + const args = ["-f"]; + const options = { f: { type: "string" } }; + const expected = { values: { __proto__: null, f: true }, positionals: [] }; + const result = parseArgs({ strict: false, args, options }); + expect(result).toEqual(expected); +}); + +test("short option group behaves like multiple short options", () => { + const args = ["-rf"]; + const options = {}; + const expected = { values: { __proto__: null, r: true, f: true }, positionals: [] }; + const result = parseArgs({ strict: false, args, options }); + expect(result).toEqual(expected); +}); + +test("short option group does not consume subsequent positional", () => { + const args = ["-rf", "foo"]; + const options = {}; + const expected = { values: { __proto__: null, r: true, f: true }, positionals: ["foo"] }; + const result = parseArgs({ strict: false, args, options }); + expect(result).toEqual(expected); +}); + +// See: Guideline 5 https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html +test('if terminal of short-option group configured `type: "string"`, subsequent positional is stored', () => { + const args = ["-rvf", "foo"]; + const options = { f: { type: "string" } }; + const expected = { values: { __proto__: null, r: true, v: true, f: "foo" }, positionals: [] }; + const result = parseArgs({ strict: false, args, options }); + expect(result).toEqual(expected); +}); + +test("handles short-option groups in conjunction with long-options", () => { + const args = ["-rf", "--foo", "foo"]; + const options = { foo: { type: "string" } }; + const expected = { values: { __proto__: null, r: true, f: true, foo: "foo" }, positionals: [] }; + const result = parseArgs({ strict: false, args, options }); + expect(result).toEqual(expected); +}); + +test('handles short-option groups with "short" alias configured', () => { + const args = ["-rf"]; + const options = { remove: { short: "r", type: "boolean" } }; + const expected = { values: { __proto__: null, remove: true, f: true }, positionals: [] }; + const result = parseArgs({ strict: false, args, options }); + expect(result).toEqual(expected); +}); + +test("handles short-option followed by its value", () => { + const args = ["-fFILE"]; + const options = { foo: { short: "f", type: "string" } }; + const expected = { values: { __proto__: null, foo: "FILE" }, positionals: [] }; + const result = parseArgs({ strict: false, args, options }); + expect(result).toEqual(expected); +}); + +test("Everything after a bare `--` is considered a positional argument", () => { + const args = ["--", "barepositionals", "mopositionals"]; + const expected = { values: { __proto__: null }, positionals: ["barepositionals", "mopositionals"] }; + const result = parseArgs({ allowPositionals: true, args }); + expect(result).toEqual(expected); // Error('testing bare positionals') +}); + +test("args are true", () => { + const args = ["--foo", "--bar"]; + const expected = { values: { __proto__: null, foo: true, bar: true }, positionals: [] }; + const result = parseArgs({ strict: false, args }); + expect(result).toEqual(expected); // Error('args are true') +}); + +test("arg is true and positional is identified", () => { + const args = ["--foo=a", "--foo", "b"]; + const expected = { values: { __proto__: null, foo: true }, positionals: ["b"] }; + const result = parseArgs({ strict: false, args }); + expect(result).toEqual(expected); // Error('arg is true and positional is identified') +}); + +test('args equals are passed `type: "string"`', () => { + const args = ["--so=wat"]; + const options = { so: { type: "string" } }; + const expected = { values: { __proto__: null, so: "wat" }, positionals: [] }; + const result = parseArgs({ args, options }); + expect(result).toEqual(expected); // Error('arg value is passed') +}); + +test("when args include single dash then result stores dash as positional", () => { + const args = ["-"]; + const expected = { values: { __proto__: null }, positionals: ["-"] }; + const result = parseArgs({ allowPositionals: true, args }); + expect(result).toEqual(expected); +}); + +test('zero config args equals are parsed as if `type: "string"`', () => { + const args = ["--so=wat"]; + const options = {}; + const expected = { values: { __proto__: null, so: "wat" }, positionals: [] }; + const result = parseArgs({ strict: false, args, options }); + expect(result).toEqual(expected); // Error('arg value is passed') +}); + +test('same arg is passed twice `type: "string"` and last value is recorded', () => { + const args = ["--foo=a", "--foo", "b"]; + const options = { foo: { type: "string" } }; + const expected = { values: { __proto__: null, foo: "b" }, positionals: [] }; + const result = parseArgs({ args, options }); + expect(result).toEqual(expected); // Error('last arg value is passed') +}); + +test("args equals pass string including more equals", () => { + const args = ["--so=wat=bing"]; + const options = { so: { type: "string" } }; + const expected = { values: { __proto__: null, so: "wat=bing" }, positionals: [] }; + const result = parseArgs({ args, options }); + expect(result).toEqual(expected); // Error('arg value is passed') +}); + +test('first arg passed for `type: "string"` and "multiple" is in array', () => { + const args = ["--foo=a"]; + const options = { foo: { type: "string", multiple: true } }; + const expected = { values: { __proto__: null, foo: ["a"] }, positionals: [] }; + const result = parseArgs({ args, options }); + expect(result).toEqual(expected); // Error('first multiple in array') +}); + +test('args are passed `type: "string"` and "multiple"', () => { + const args = ["--foo=a", "--foo", "b"]; + const options = { + foo: { + type: "string", + multiple: true, + }, + }; + const expected = { values: { __proto__: null, foo: ["a", "b"] }, positionals: [] }; + const result = parseArgs({ args, options }); + expect(result).toEqual(expected); // Error('both arg values are passed') +}); + +test( + "when expecting `multiple:true` boolean option and option used multiple times then result includes array of " + + "booleans matching usage", + () => { + const args = ["--foo", "--foo"]; + const options = { + foo: { + type: "boolean", + multiple: true, + }, + }; + const expected = { values: { __proto__: null, foo: [true, true] }, positionals: [] }; + const result = parseArgs({ args, options }); + expect(result).toEqual(expected); + }, +); + +test("order of option and positional does not matter (per README)", () => { + const args1 = ["--foo=bar", "baz"]; + const args2 = ["baz", "--foo=bar"]; + const options = { foo: { type: "string" } }; + const expected = { values: { __proto__: null, foo: "bar" }, positionals: ["baz"] }; + expect(parseArgs({ allowPositionals: true, args: args1, options })).toStrictEqual(expected); // Error("option then positional") + expect(parseArgs({ allowPositionals: true, args: args2, options })).toStrictEqual(expected); //Error("positional then option") +}); + +test("excess leading dashes on options are retained", () => { + // Enforce a design decision for an edge case. + const args = ["---triple"]; + const options = {}; + const expected = { + values: { "__proto__": null, "-triple": true }, + positionals: [], + }; + const result = parseArgs({ strict: false, args, options }); + expect(result).toEqual(expected); // Error('excess option dashes are retained') +}); + +test("positional arguments are allowed by default in strict:false", () => { + const args = ["foo"]; + const options = {}; + const expected = { + values: { __proto__: null }, + positionals: ["foo"], + }; + const result = parseArgs({ strict: false, args, options }); + expect(result).toEqual(expected); +}); + +test("positional arguments may be explicitly disallowed in strict:false", () => { + const args = ["foo"]; + const options = {}; + expectToThrowErrorMatching( + () => { + parseArgs({ strict: false, allowPositionals: false, args, options }); + }, + { + code: "ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL", + }, + ); +}); + +// Test bad inputs + +test("invalid argument passed for options", () => { + const args = ["--so=wat"]; + const options = "bad value"; + expectToThrowErrorMatching( + () => { + parseArgs({ args, options }); + }, + { + code: "ERR_INVALID_ARG_TYPE", + }, + ); +}); + +test("type property missing for option then throw", () => { + const knownOptions = { foo: {} }; + expectToThrowErrorMatching( + () => { + parseArgs({ options: knownOptions }); + }, + { + code: "ERR_INVALID_ARG_TYPE", + }, + ); +}); + +test('boolean passed to "type" option', () => { + const args = ["--so=wat"]; + const options = { foo: { type: true } }; + expectToThrowErrorMatching( + () => { + parseArgs({ args, options }); + }, + { + code: "ERR_INVALID_ARG_TYPE", + }, + ); +}); + +test('invalid union value passed to "type" option', () => { + const args = ["--so=wat"]; + const options = { foo: { type: "str" } }; + expectToThrowErrorMatching( + () => { + parseArgs({ args, options }); + }, + { + code: "ERR_INVALID_ARG_TYPE", + }, + ); +}); + +// Test strict mode + +test("unknown long option --bar", () => { + const args = ["--foo", "--bar"]; + const options = { foo: { type: "boolean" } }; + expectToThrowErrorMatching( + () => { + parseArgs({ args, options }); + }, + { + code: "ERR_PARSE_ARGS_UNKNOWN_OPTION", + }, + ); +}); + +test("unknown short option -b", () => { + const args = ["--foo", "-b"]; + const options = { foo: { type: "boolean" } }; + expectToThrowErrorMatching( + () => { + parseArgs({ args, options }); + }, + { + code: "ERR_PARSE_ARGS_UNKNOWN_OPTION", + }, + ); +}); + +test("unknown option -r in short option group -bar", () => { + const args = ["-bar"]; + const options = { b: { type: "boolean" }, a: { type: "boolean" } }; + expectToThrowErrorMatching( + () => { + parseArgs({ args, options }); + }, + { + code: "ERR_PARSE_ARGS_UNKNOWN_OPTION", + }, + ); +}); + +test("unknown option with explicit value", () => { + const args = ["--foo", "--bar=baz"]; + const options = { foo: { type: "boolean" } }; + expectToThrowErrorMatching( + () => { + parseArgs({ args, options }); + }, + { + code: "ERR_PARSE_ARGS_UNKNOWN_OPTION", + }, + ); +}); + +test("unexpected positional", () => { + const args = ["foo"]; + const options = { foo: { type: "boolean" } }; + expectToThrowErrorMatching( + () => { + parseArgs({ args, options }); + }, + { + code: "ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL", + }, + ); +}); + +test("unexpected positional after --", () => { + const args = ["--", "foo"]; + const options = { foo: { type: "boolean" } }; + expectToThrowErrorMatching( + () => { + parseArgs({ args, options }); + }, + { + code: "ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL", + }, + ); +}); + +test("-- by itself is not a positional", () => { + const args = ["--foo", "--"]; + const options = { foo: { type: "boolean" } }; + const result = parseArgs({ args, options }); + const expected = { values: { __proto__: null, foo: true }, positionals: [] }; + expect(result).toEqual(expected); +}); + +test("string option used as boolean", () => { + const args = ["--foo"]; + const options = { foo: { type: "string" } }; + expectToThrowErrorMatching( + () => { + parseArgs({ args, options }); + }, + { + code: "ERR_PARSE_ARGS_INVALID_OPTION_VALUE", + }, + ); +}); + +test("boolean option used with value", () => { + const args = ["--foo=bar"]; + const options = { foo: { type: "boolean" } }; + expectToThrowErrorMatching( + () => { + parseArgs({ args, options }); + }, + { + code: "ERR_PARSE_ARGS_INVALID_OPTION_VALUE", + }, + ); +}); + +test("invalid short option length", () => { + const args = []; + const options = { foo: { short: "fo", type: "boolean" } }; + expectToThrowErrorMatching( + () => { + parseArgs({ args, options }); + }, + { + code: "ERR_INVALID_ARG_VALUE", + }, + ); +}); + +test("null prototype: when no options then values.toString is undefined", () => { + const result = parseArgs({ args: [] }); + expect(result.values.toString).toBe(undefined); +}); + +test("null prototype: when --toString then values.toString is true", () => { + const args = ["--toString"]; + const options = { toString: { type: "boolean" } }; + const expectedResult = { values: { __proto__: null, toString: true }, positionals: [] }; + + const result = parseArgs({ args, options }); + expect(result).toStrictEqual(expectedResult); +}); + +const candidateGreedyOptions = ["", "-", "--", "abc", "123", "-s", "--foo"]; + +for (const value of candidateGreedyOptions) { + test(`greedy: when short option with value '${value}' then eaten`, () => { + const args = ["-w", value]; + const options = { with: { type: "string", short: "w" } }; + const expectedResult = { values: { __proto__: null, with: value }, positionals: [] }; + + const result = parseArgs({ args, options, strict: false }); + expect(result).toStrictEqual(expectedResult); + }); + + test(`greedy: when long option with value '${value}' then eaten`, () => { + const args = ["--with", value]; + const options = { with: { type: "string", short: "w" } }; + const expectedResult = { values: { __proto__: null, with: value }, positionals: [] }; + + const result = parseArgs({ args, options, strict: false }); + expect(result).toStrictEqual(expectedResult); + }); +} + +test("strict: when candidate option value is plain text then does not throw", () => { + const args = ["--with", "abc"]; + const options = { with: { type: "string" } }; + const expectedResult = { values: { __proto__: null, with: "abc" }, positionals: [] }; + + const result = parseArgs({ args, options, strict: true }); + expect(result).toStrictEqual(expectedResult); +}); + +test("strict: when candidate option value is '-' then does not throw", () => { + const args = ["--with", "-"]; + const options = { with: { type: "string" } }; + const expectedResult = { values: { __proto__: null, with: "-" }, positionals: [] }; + + const result = parseArgs({ args, options, strict: true }); + expect(result).toStrictEqual(expectedResult); +}); + +test("strict: when candidate option value is '--' then throws", () => { + const args = ["--with", "--"]; + const options = { with: { type: "string" } }; + + expectToThrowErrorMatching( + () => { + parseArgs({ args, options }); + }, + { + code: "ERR_PARSE_ARGS_INVALID_OPTION_VALUE", + }, + ); +}); + +test("strict: when candidate option value is short option then throws", () => { + const args = ["--with", "-a"]; + const options = { with: { type: "string" } }; + + expectToThrowErrorMatching( + () => { + parseArgs({ args, options }); + }, + { + code: "ERR_PARSE_ARGS_INVALID_OPTION_VALUE", + }, + ); +}); + +test("strict: when candidate option value is short option digit then throws", () => { + const args = ["--with", "-1"]; + const options = { with: { type: "string" } }; + + expectToThrowErrorMatching( + () => { + parseArgs({ args, options }); + }, + { + code: "ERR_PARSE_ARGS_INVALID_OPTION_VALUE", + }, + ); +}); + +test("strict: when candidate option value is long option then throws", () => { + const args = ["--with", "--foo"]; + const options = { with: { type: "string" } }; + + expectToThrowErrorMatching( + () => { + parseArgs({ args, options }); + }, + { + code: "ERR_PARSE_ARGS_INVALID_OPTION_VALUE", + }, + ); +}); + +test("strict: when short option and suspect value then throws with short option in error message", () => { + const args = ["-w", "--foo"]; + const options = { with: { type: "string", short: "w" } }; + + expect(() => { + parseArgs({ args, options }); + }).toThrow(/for '-w'/); +}); + +test("strict: when long option and suspect value then throws with long option in error message", () => { + const args = ["--with", "--foo"]; + const options = { with: { type: "string" } }; + + expect(() => { + parseArgs({ args, options }); + }).toThrow(/for '--with'/); +}); + +test("strict: when short option and suspect value then throws with whole expected message", () => { + const args = ["-w", "--foo"]; + const options = { with: { type: "string", short: "w" } }; + + try { + parseArgs({ args, options }); + } catch (err) { + console.info(err.message); + } + + expect(() => { + parseArgs({ args, options }); + }).toThrow(/To specify an option argument starting with a dash use '--with=-XYZ' or '-w-XYZ'/); +}); + +test("strict: when long option and suspect value then throws with whole expected message", () => { + const args = ["--with", "--foo"]; + const options = { with: { type: "string", short: "w" } }; + + expect(() => { + parseArgs({ args, options }); + }).toThrow(/To specify an option argument starting with a dash use '--with=-XYZ'/); +}); + +test("tokens: positional", () => { + const args = ["one"]; + const expectedTokens = [{ kind: "positional", index: 0, value: "one" }]; + const { tokens } = parseArgs({ strict: false, args, tokens: true }); + expect(tokens).toStrictEqual(expectedTokens); +}); + +test("tokens: -- followed by option-like", () => { + const args = ["--", "--foo"]; + const expectedTokens = [ + { kind: "option-terminator", index: 0 }, + { kind: "positional", index: 1, value: "--foo" }, + ]; + const { tokens } = parseArgs({ strict: false, args, tokens: true }); + expect(tokens).toStrictEqual(expectedTokens); +}); + +test("tokens: strict:true boolean short", () => { + const args = ["-f"]; + const options = { + file: { short: "f", type: "boolean" }, + }; + const expectedTokens = [ + { kind: "option", name: "file", rawName: "-f", index: 0, value: undefined, inlineValue: undefined }, + ]; + const { tokens } = parseArgs({ strict: true, args, options, tokens: true }); + expect(tokens).toStrictEqual(expectedTokens); +}); + +test("tokens: strict:true boolean long", () => { + const args = ["--file"]; + const options = { + file: { short: "f", type: "boolean" }, + }; + const expectedTokens = [ + { kind: "option", name: "file", rawName: "--file", index: 0, value: undefined, inlineValue: undefined }, + ]; + const { tokens } = parseArgs({ strict: true, args, options, tokens: true }); + expect(tokens).toStrictEqual(expectedTokens); +}); + +test("tokens: strict:false boolean short", () => { + const args = ["-f"]; + const expectedTokens = [ + { kind: "option", name: "f", rawName: "-f", index: 0, value: undefined, inlineValue: undefined }, + ]; + const { tokens } = parseArgs({ strict: false, args, tokens: true }); + expect(tokens).toStrictEqual(expectedTokens); +}); + +test("tokens: strict:false boolean long", () => { + const args = ["--file"]; + const expectedTokens = [ + { kind: "option", name: "file", rawName: "--file", index: 0, value: undefined, inlineValue: undefined }, + ]; + const { tokens } = parseArgs({ strict: false, args, tokens: true }); + expect(tokens).toStrictEqual(expectedTokens); +}); + +test("tokens: strict:false boolean option group", () => { + const args = ["-ab"]; + const expectedTokens = [ + { kind: "option", name: "a", rawName: "-a", index: 0, value: undefined, inlineValue: undefined }, + { kind: "option", name: "b", rawName: "-b", index: 0, value: undefined, inlineValue: undefined }, + ]; + const { tokens } = parseArgs({ strict: false, args, tokens: true }); + expect(tokens).toStrictEqual(expectedTokens); +}); + +test("tokens: strict:false boolean option group with repeated option", () => { + // Also positional to check index correct after grouop + const args = ["-aa", "pos"]; + const expectedTokens = [ + { kind: "option", name: "a", rawName: "-a", index: 0, value: undefined, inlineValue: undefined }, + { kind: "option", name: "a", rawName: "-a", index: 0, value: undefined, inlineValue: undefined }, + { kind: "positional", index: 1, value: "pos" }, + ]; + const { tokens } = parseArgs({ strict: false, allowPositionals: true, args, tokens: true }); + expect(tokens).toStrictEqual(expectedTokens); +}); + +test("tokens: strict:true string short with value after space", () => { + // Also positional to check index correct after out-of-line. + const args = ["-f", "bar", "ppp"]; + const options = { + file: { short: "f", type: "string" }, + }; + const expectedTokens = [ + { kind: "option", name: "file", rawName: "-f", index: 0, value: "bar", inlineValue: false }, + { kind: "positional", index: 2, value: "ppp" }, + ]; + const { tokens } = parseArgs({ strict: true, allowPositionals: true, args, options, tokens: true }); + expect(tokens).toStrictEqual(expectedTokens); +}); + +test("tokens: strict:true string short with value inline", () => { + const args = ["-fBAR"]; + const options = { + file: { short: "f", type: "string" }, + }; + const expectedTokens = [{ kind: "option", name: "file", rawName: "-f", index: 0, value: "BAR", inlineValue: true }]; + const { tokens } = parseArgs({ strict: true, args, options, tokens: true }); + expect(tokens).toStrictEqual(expectedTokens); +}); + +test("tokens: strict:false string short missing value", () => { + const args = ["-f"]; + const options = { + file: { short: "f", type: "string" }, + }; + const expectedTokens = [ + { kind: "option", name: "file", rawName: "-f", index: 0, value: undefined, inlineValue: undefined }, + ]; + const { tokens } = parseArgs({ strict: false, args, options, tokens: true }); + expect(tokens).toStrictEqual(expectedTokens); +}); + +test("tokens: strict:true string long with value after space", () => { + // Also positional to check index correct after out-of-line. + const args = ["--file", "bar", "ppp"]; + const options = { + file: { short: "f", type: "string" }, + }; + const expectedTokens = [ + { kind: "option", name: "file", rawName: "--file", index: 0, value: "bar", inlineValue: false }, + { kind: "positional", index: 2, value: "ppp" }, + ]; + const { tokens } = parseArgs({ strict: true, allowPositionals: true, args, options, tokens: true }); + expect(tokens).toStrictEqual(expectedTokens); +}); + +test("tokens: strict:true string long with value inline", () => { + // Also positional to check index correct after out-of-line. + const args = ["--file=bar", "pos"]; + const options = { + file: { short: "f", type: "string" }, + }; + const expectedTokens = [ + { kind: "option", name: "file", rawName: "--file", index: 0, value: "bar", inlineValue: true }, + { kind: "positional", index: 1, value: "pos" }, + ]; + const { tokens } = parseArgs({ strict: true, allowPositionals: true, args, options, tokens: true }); + expect(tokens).toStrictEqual(expectedTokens); +}); + +test("tokens: strict:false string long with value inline", () => { + const args = ["--file=bar"]; + const expectedTokens = [ + { kind: "option", name: "file", rawName: "--file", index: 0, value: "bar", inlineValue: true }, + ]; + const { tokens } = parseArgs({ strict: false, args, tokens: true }); + expect(tokens).toStrictEqual(expectedTokens); +}); + +test("tokens: strict:false string long missing value", () => { + const args = ["--file"]; + const options = { + file: { short: "f", type: "string" }, + }; + const expectedTokens = [ + { kind: "option", name: "file", rawName: "--file", index: 0, value: undefined, inlineValue: undefined }, + ]; + const { tokens } = parseArgs({ strict: false, args, options, tokens: true }); + expect(tokens).toStrictEqual(expectedTokens); +}); + +test("tokens: strict:true complex option group with value after space", () => { + // Also positional to check index correct afterwards. + const args = ["-ab", "c", "pos"]; + const options = { + alpha: { short: "a", type: "boolean" }, + beta: { short: "b", type: "string" }, + }; + const expectedTokens = [ + { kind: "option", name: "alpha", rawName: "-a", index: 0, value: undefined, inlineValue: undefined }, + { kind: "option", name: "beta", rawName: "-b", index: 0, value: "c", inlineValue: false }, + { kind: "positional", index: 2, value: "pos" }, + ]; + const { tokens } = parseArgs({ strict: true, allowPositionals: true, args, options, tokens: true }); + expect(tokens).toStrictEqual(expectedTokens); +}); + +test("tokens: strict:true complex option group with inline value", () => { + // Also positional to check index correct afterwards. + const args = ["-abc", "pos"]; + const options = { + alpha: { short: "a", type: "boolean" }, + beta: { short: "b", type: "string" }, + }; + const expectedTokens = [ + { kind: "option", name: "alpha", rawName: "-a", index: 0, value: undefined, inlineValue: undefined }, + { kind: "option", name: "beta", rawName: "-b", index: 0, value: "c", inlineValue: true }, + { kind: "positional", index: 1, value: "pos" }, + ]; + const { tokens } = parseArgs({ strict: true, allowPositionals: true, args, options, tokens: true }); + expect(tokens).toStrictEqual(expectedTokens); +}); + +test("tokens: strict:false with single dashes", () => { + const args = ["--file", "-", "-"]; + const options = { + file: { short: "f", type: "string" }, + }; + const expectedTokens = [ + { kind: "option", name: "file", rawName: "--file", index: 0, value: "-", inlineValue: false }, + { kind: "positional", index: 2, value: "-" }, + ]; + const { tokens } = parseArgs({ strict: false, args, options, tokens: true }); + expect(tokens).toStrictEqual(expectedTokens); +}); + +test("tokens: strict:false with -- --", () => { + const args = ["--", "--"]; + const expectedTokens = [ + { kind: "option-terminator", index: 0 }, + { kind: "positional", index: 1, value: "--" }, + ]; + const { tokens } = parseArgs({ strict: false, args, tokens: true }); + expect(tokens).toStrictEqual(expectedTokens); +}); + +test("default must be a boolean when option type is boolean", () => { + const args = []; + const options = { alpha: { type: "boolean", default: "not a boolean" } }; + expect(() => { + parseArgs({ args, options }); + }).toThrow(/"options\.alpha\.default" property must be of type boolean/); +}); + +test("default must accept undefined value", () => { + const args = []; + const options = { alpha: { type: "boolean", default: undefined } }; + const result = parseArgs({ args, options }); + const expected = { + values: { + __proto__: null, + }, + positionals: [], + }; + expect(result).toEqual(expected); +}); + +test("default must be a boolean array when option type is boolean and multiple", () => { + const args = []; + const options = { alpha: { type: "boolean", multiple: true, default: "not an array" } }; + expect(() => { + parseArgs({ args, options }); + }).toThrow(/"options\.alpha\.default" property must be an instance of Array/); +}); + +test("default must be a boolean array when option type is string and multiple is true", () => { + const args = []; + const options = { alpha: { type: "boolean", multiple: true, default: [true, true, 42] } }; + expect(() => { + parseArgs({ args, options }); + }).toThrow(/"options\.alpha\.default\[2\]" property must be of type boolean/); +}); + +test("default must be a string when option type is string", () => { + const args = []; + const options = { alpha: { type: "string", default: true } }; + expect(() => { + parseArgs({ args, options }); + }).toThrow(/"options\.alpha\.default" property must be of type string/); +}); + +test("default must be an array when option type is string and multiple is true", () => { + const args = []; + const options = { alpha: { type: "string", multiple: true, default: "not an array" } }; + expect(() => { + parseArgs({ args, options }); + }).toThrow(/"options\.alpha\.default" property must be an instance of Array/); +}); + +test("default must be a string array when option type is string and multiple is true", () => { + const args = []; + const options = { alpha: { type: "string", multiple: true, default: ["str", 42] } }; + expect(() => { + parseArgs({ args, options }); + }).toThrow(/"options\.alpha\.default\[1\]" property must be of type string/); +}); + +test("default accepted input when multiple is true", () => { + const args = ["--inputStringArr", "c", "--inputStringArr", "d", "--inputBoolArr", "--inputBoolArr"]; + const options = { + inputStringArr: { type: "string", multiple: true, default: ["a", "b"] }, + emptyStringArr: { type: "string", multiple: true, default: [] }, + fullStringArr: { type: "string", multiple: true, default: ["a", "b"] }, + inputBoolArr: { type: "boolean", multiple: true, default: [false, true, false] }, + emptyBoolArr: { type: "boolean", multiple: true, default: [] }, + fullBoolArr: { type: "boolean", multiple: true, default: [false, true, false] }, + }; + const expected = { + values: { + __proto__: null, + inputStringArr: ["c", "d"], + inputBoolArr: [true, true], + emptyStringArr: [], + fullStringArr: ["a", "b"], + emptyBoolArr: [], + fullBoolArr: [false, true, false], + }, + positionals: [], + }; + const result = parseArgs({ args, options }); + expect(result).toEqual(expected); +}); + +test("when default is set, the option must be added as result", () => { + const args = []; + const options = { + a: { type: "string", default: "HELLO" }, + b: { type: "boolean", default: false }, + c: { type: "boolean", default: true }, + }; + const expected = { values: { __proto__: null, a: "HELLO", b: false, c: true }, positionals: [] }; + + const result = parseArgs({ args, options }); + expect(result).toEqual(expected); +}); + +test("when default is set, the args value takes precedence", () => { + const args = ["--a", "WORLD", "--b", "-c"]; + const options = { + a: { type: "string", default: "HELLO" }, + b: { type: "boolean", default: false }, + c: { type: "boolean", default: true }, + }; + const expected = { values: { __proto__: null, a: "WORLD", b: true, c: true }, positionals: [] }; + + const result = parseArgs({ args, options }); + expect(result).toEqual(expected); +}); + +test("tokens should not include the default options", () => { + const args = []; + const options = { + a: { type: "string", default: "HELLO" }, + b: { type: "boolean", default: false }, + c: { type: "boolean", default: true }, + }; + + const expectedTokens = []; + + const { tokens } = parseArgs({ args, options, tokens: true }); + expect(tokens).toStrictEqual(expectedTokens); +}); + +test("tokens:true should not include the default options after the args input", () => { + const args = ["--z", "zero", "positional-item"]; + const options = { + z: { type: "string" }, + a: { type: "string", default: "HELLO" }, + b: { type: "boolean", default: false }, + c: { type: "boolean", default: true }, + }; + + const expectedTokens = [ + { kind: "option", name: "z", rawName: "--z", index: 0, value: "zero", inlineValue: false }, + { kind: "positional", index: 2, value: "positional-item" }, + ]; + + const { tokens } = parseArgs({ args, options, tokens: true, allowPositionals: true }); + expect(tokens).toStrictEqual(expectedTokens); +}); + +test("proto as default value must be ignored", () => { + const args = []; + const options = { __proto__: null }; + + // eslint-disable-next-line no-proto + options.__proto__ = { type: "string", default: "HELLO" }; + + const result = parseArgs({ args, options, allowPositionals: true }); + const expected = { values: { __proto__: null }, positionals: [] }; + expect(result).toEqual(expected); +}); + +test("multiple as false should expect a String", () => { + const args = []; + const options = { alpha: { type: "string", multiple: false, default: ["array"] } }; + expect(() => { + parseArgs({ args, options }); + }).toThrow(/"options\.alpha\.default" property must be of type string/); +});