std/getopt

Standard Library source code

Parse command line arguments.

Module

Name
std/getopt
Area
Standard Library
Source
modules/std/getopt.zzm
=encoding utf8

=head1 NAME

std/getopt - Parse command line arguments.

=head1 SYNOPSIS

  from std/getopt import Getopt;

  function __main__ (argv) {
    let parsed := Getopt.parse(
      argv,
      [ "help|h", "verbose|v", "count|c=i", "name|n=s" ]
    );

    if ( not parsed{ok} ) {
      say( parsed{error} );
      return 2;
    }

    let opts := parsed{options};
    let rest := parsed{argv};

    if ( opts{help} ) {
      say( "usage: tool [--count N] [--name STR] args..." );
      return 0;
    }

    say( "remaining args = " + rest.length() );
    return 0;
  }

=head1 IMPLEMENTATION SUPPORT

This module is supported by all implementations of ZuzuScript.

=head1 DESCRIPTION

This module parses command-line option arrays for ZuzuScript programs.

Use C<Getopt.parse(argv, specs, config?)> and pass the C<argv> array
that your C<__main__> function receives.

It intentionally does not read a host process argument global.

=head1 EXPORTS

=head2 Classes

=over

=item C<Getopt>

Static methods:

=over

=item * C<parse(Array argv, Array specs, Array config?)>

Parameters: C<argv> is the command-line argument array, C<specs> is an
option-spec array, and C<config> is optional parser configuration.
Returns: C<Dict>. Parses arguments into options and remaining
positional arguments.

=item * C<schema(Array argv, Array schema, Array config?)>

Parameters: C<argv> is the command-line argument array, C<schema> is an
array of option schema dictionaries, and C<config> is optional parser
configuration. Returns: C<Dict>. Parses arguments using structured
option metadata and produces errors and usage text.

Schema entries use fields such as C<name>, C<short>,
C<type> (for example C<Number>, C<String>, C<Boolean>),
C<required> (Boolean), C<default>, C<multiple>, and C<desc>.

Returns a dictionary:

=over

=item * C<ok> (Boolean-like Number)

=item * C<options> (Dict)

=item * C<argv> (remaining positional args)

=item * C<error> (String or null)

=item * C<errors> (Array, for C<schema>)

=item * C<usage> (String, for C<schema>)

=back

=back

=back

=head1 COPYRIGHT AND LICENCE

B<< std/getopt >> is copyright Toby Inkster.

It is free software; you may redistribute it and/or modify it under
the terms of either the Artistic License 1.0 or the GNU General Public
License version 2.

=cut

from std/string import split, substr, starts_with, ends_with, join;


function _spec_kind_from_suffix ( suffix ) {
	if ( suffix ≡ "i" ) {
		return "integer";
	}
	if ( suffix ≡ "f" ) {
		return "number";
	}
	if ( suffix ≡ "s" ) {
		return "string";
	}
	return "flag";
}

function _normalize_schema_type ( raw_type ) {
	if ( raw_type ≡ null ) {
		return "Boolean";
	}
	if ( raw_type instanceof Function ) {
		let function_name := null;
		try {
			function_name := raw_type.name;
		}
		catch {
		}
		if (
			function_name ≡ "Boolean" or
			function_name ≡ "Number" or
			function_name ≡ "String"
		) {
			return function_name;
		}
	}
	if ( raw_type ≡ Boolean ) {
		return "Boolean";
	}
	if ( raw_type ≡ Number ) {
		return "Number";
	}
	if ( raw_type ≡ String ) {
		return "String";
	}
	let type_name := "" _ raw_type;
	if ( type_name ≡ "" ) {
		return "Boolean";
	}
	return type_name;
}

function _schema_type_suffix ( type_name ) {
	let lowered := lc( "" _ type_name );
	if ( lowered ≡ "boolean" or lowered ≡ "bool" ) {
		return "";
	}
	if ( lowered ≡ "number" or lowered ≡ "num" ) {
		return "=f";
	}
	if ( lowered ≡ "int" or lowered ≡ "integer" ) {
		return "=i";
	}
	return "=s";
}

function _parse_spec ( spec ) {
	let out := {
		name: "",
		short: "",
		suffix: "",
		kind: "flag",
		multiple: 0,
	};

	let base := "" _ spec;
	let spec_parts := split(base, "=");
	let names_part := spec_parts[0];
	if ( spec_parts.length() > 1 ) {
		let rhs := spec_parts[1];
		if ( rhs ≢ null and rhs ≢ "" ) {
			out{suffix} := substr(rhs, 0, 1);
		}
	}

	let aliases := split(names_part, "|");
	if ( aliases.length() > 0 and aliases[0] ≢ null ) {
		out{name} := "" _ aliases[0];
	}
	if ( aliases.length() > 1 and aliases[1] ≢ null ) {
		out{short} := "" _ aliases[1];
	}

	if ( ends_with(base, "@") ) {
		out{multiple} := 1;
	}

	out{kind} := _spec_kind_from_suffix( out{suffix} );
	return out;
}

function _coerce_value ( kind, raw ) {
	if ( kind ≡ "flag" ) {
		return 1;
	}
	if ( kind ≡ "integer" ) {
		return int(raw);
	}
	if ( kind ≡ "number" ) {
		return 0 + raw;
	}
	return "" _ raw;
}

function _parse_from_specs ( argv, specs ) {
	let parsed_specs := [];
	let long_lookup := {};
	let short_lookup := {};
	for ( let spec_text in specs ) {
		let spec := _parse_spec(spec_text);
		if ( spec{name} ≡ "" ) {
			next;
		}
		parsed_specs.push(spec);
			long_lookup{( spec{name} )} := spec;
			if ( spec{short} ≢ "" ) {
				short_lookup{( spec{short} )} := spec;
			}
	}

	let remaining := [];
	let options := {};
	let parse_error := null;
	let i := 0;
	while ( i < argv.length() ) {
		let arg := "" _ argv[i];
		if ( arg ≡ "--" ) {
			let j := i + 1;
			while ( j < argv.length() ) {
				remaining.push( "" _ argv[j] );
				j++;
			}
			last;
		}

		if ( starts_with(arg, "--") and length arg > 2 ) {
			let raw := substr(arg, 2);
			let name := raw;
			let inline_value := null;
			if ( raw ~ /=/ ) {
				let parts := split(raw, "=");
				name := parts[0];
				inline_value := join( "=", parts.slice(1) );
			}
			if ( not( name in long_lookup ) ) {
				parse_error := `Unknown option --${name}`;
				last;
			}
				let spec := long_lookup{( name )};
			if ( spec{kind} ≡ "flag" ) {
				options{( spec{name} )} := 1;
				i++;
				next;
			}

			let value_text := inline_value;
			if ( value_text ≡ null ) {
				if ( i + 1 >= argv.length() ) {
					parse_error := `Option --${name} requires a value`;
					last;
				}
				value_text := "" _ argv[i + 1];
				i++;
			}

			let coerced := _coerce_value( spec{kind}, value_text );
			if ( spec{multiple} ) {
				if ( not( spec{name} in options ) ) {
					options{( spec{name} )} := [];
				}
				options{( spec{name} )}.push(coerced);
			}
			else {
				options{( spec{name} )} := coerced;
			}
			i++;
			next;
		}

		if ( starts_with(arg, "-") and length arg > 1 ) {
			let short_name := substr(arg, 1);
			if ( not( short_name in short_lookup ) ) {
				parse_error := `Unknown option -${short_name}`;
				last;
			}
				let spec := short_lookup{( short_name )};
			if ( spec{kind} ≡ "flag" ) {
				options{( spec{name} )} := 1;
				i++;
				next;
			}
			if ( i + 1 >= argv.length() ) {
				parse_error := `Option -${short_name} requires a value`;
				last;
			}
			let coerced := _coerce_value( spec{kind}, "" _ argv[i + 1] );
			if ( spec{multiple} ) {
				if ( not( spec{name} in options ) ) {
					options{( spec{name} )} := [];
				}
				options{( spec{name} )}.push(coerced);
			}
			else {
				options{( spec{name} )} := coerced;
			}
			i += 2;
			next;
		}

		remaining.push(arg);
		i++;
	}

	return {
		ok: parse_error ≡ null ? 1: 0,
		options: options,
		argv: remaining,
		error: parse_error,
		specs: parsed_specs,
	};
}

class Getopt {
	static method parse ( argv, specs, config? ) {
		let parsed_argv := argv instanceof Array ? argv: [];
		let parsed_specs := specs instanceof Array ? specs: [];
		let _cfg := config;
		let result := _parse_from_specs( parsed_argv, parsed_specs );
		return {
			ok: result{ok},
			options: result{options},
			argv: result{argv},
			error: result{error},
		};
	}

	static method schema ( argv, schema, config? ) {
		let parsed_argv := argv instanceof Array ? argv: [];
		let parsed_schema := schema instanceof Array ? schema: [];
		let _cfg := config;
		let specs := [];
		let usage_lines := [];
		let meta := {};

		for ( let entry in parsed_schema ) {
			if ( not( entry instanceof Dict ) ) {
				next;
			}
			if ( not( "name" in entry ) ) {
				next;
			}
			let name := "" _ entry{name};
			if ( name ≡ "" ) {
				next;
			}
			let short := entry{short} ≡ null ? "": "" _ entry{short};
			let type_name := _normalize_schema_type( entry{type} );
			let suffix := _schema_type_suffix(type_name);
			let is_multiple := entry{multiple} ? 1: 0;
			if ( is_multiple ) {
				suffix _= "@";
			}
			let spec := short ≡ "" ? `${name}${suffix}`: `${name}|${short}${suffix}`;
			specs.push(spec);
			meta{name} := {
				required: entry{required} ? 1: 0,
				has_default: "default" in entry ? 1: 0,
				default_value: entry{default},
			};

			let usage := `  --${name}`;
			if ( short ≢ "" ) {
				usage _= `, -${short}`;
			}
			let lowered := lc(type_name);
			if ( lowered ≢ "boolean" and lowered ≢ "bool" ) {
				usage _= ` <${type_name}>`;
			}
			if ( entry{required} ) {
				usage _= " (required)";
			}
			if ( entry{desc} ≢ null and entry{desc} ≢ "" ) {
				usage _= `  ${entry{desc}}`;
			}
			usage_lines.push(usage);
		}

		let result := _parse_from_specs( parsed_argv, specs );
		let errors := [];
		if ( result{error} ≢ null ) {
			errors.push(result{error});
		}

		for ( let name in meta.keys() ) {
			let entry := meta{name};
			if ( not( name in result{options} ) and entry{has_default} ) {
				result{options}{name} := entry{default_value};
			}
			if ( entry{required} and not( name in result{options} ) ) {
				errors.push( `missing required option --${name}` );
			}
		}

		let ok := errors.length() > 0 ? 0: result{ok};
		return {
			ok: ok,
			options: result{options},
			argv: result{argv},
			error: errors.length() > 0 ? join( "\n", errors ): null,
			errors: errors,
			usage: join( "\n", usage_lines ),
			specs: specs,
		};
	}
}