/* * Copyright 2021 Stephen Tetley * * Use of this source code is governed by the Apache 2.0 license * that can be found in the LICENSE.md file. */////// Functions for parsing command-line options.////// Acknowledgement: this is a port of Sven Panne's `System.Console.GetOpt` in the Haskell Base libraries.///pubmod Util.GetOpt {////// Type of policies for handling arguments that follow non-options.////// * `RequireOrder` after the first non-option all remain arguments are treated as non-options/// * `Permute` options and non-options are freely interspersed in the input stream/// * `ReturnInOrder` turn non-options into options by applying the supplied function///pubenumArgOrder[a] {case RequireOrdercase Permutecase ReturnInOrder(String -> a) }////// Type of option argument descriptors.////// Defines whether or not an option takes an argument.////// * `NoArg` expects no argument. The constructor takes a value of type `a` indicating the option that has been recognized./// * `ReqArg` mandates an argument. The constructor takes a function to decode the argument and description string./// * `OptArg` optionally requires an argument. The constructor takes a function to decode the argument and description string.////// The "decode" functions are expected to produce `Some(_)` when decoding is successful, and `None` to indicate/// failure.///pubenumArgDescr[a] {case NoArg(a)case ReqArg(String -> Option[a], String)case OptArg(Option[String] -> Option[a], String) }////// The kind of a command line option (internal).////// * `Opt(a)` is an option producing a value of type `a`/// * `UnreqOp` is an unrecognized option/// * `NonOpt` is a non-option, returned to the user as a String/// * `EndOfOpts` signals the end of options in the decoded command line String/// * `OptErr` is the error case with a String error message///enumOptionKind[a] {case Opt(a)case UnreqOpt(String)case NonOpt(String)case EndOfOptscase OptErr(String) }////// An `OptionDescr` describes a single command line option.////// * `optionIds` is a list of single character abbreviations identifying the option/// * `optionNames` is a list of long names identifying the option/// * `argDescriptor` defines the format of the option/// * `explanation` is the description of the option printed to the user by the function `usageInfo`///pubtypealiasOptionDescr[a] = { optionIds = List[Char], optionNames = List[String], argDescriptor = ArgDescr[a], explanation = String }////// Return a formatted string describing the usage of the command.////// Typically this output is used by "--help"///pubdefusageInfo(header: String, optionDescriptors: List[OptionDescr[a]]): String =letpaste = (x, y, z) -> " ${x} ${y} ${z}";let (shorts, longs, ds) = List.flatMap(formatOpt, optionDescriptors) |> List.unzip3;lettable = List.zipWith3(paste, sameLen(shorts), sameLen(longs), ds);String.unlines(header :: table)////// Returns `xs` with each string right-padded with spaces to the length of the longest string.///defsameLen(xs: List[String]): List[String] =letwidth = List.foldLeft((ac, x) -> Int32.max(ac, String.length(x)), 0, xs);List.map(String.padRight(width, ' '), xs)////// Format each option descriptor.///defformatOpt(opts: OptionDescr[a]): List[(String, String, String)] =letshorts = List.map(formatShort(opts#argDescriptor), opts#optionIds) |> sepBy(',');letlongs = List.map(formatLong(opts#argDescriptor), opts#optionNames) |> sepBy(',');matchString.lines(opts#explanation) {case Nil => (shorts, longs, "") :: Nilcased :: ds => (shorts, longs, d) :: List.map(x -> ("", "", x), ds) }////// Returns the string formed by separating each element in `ss` with the Char `ch` and a space.///defsepBy(ch: Char, ss: List[String]): String = String.intercalate("${ch} ", ss)////// Format a single character option.///defformatShort(argDescr: ArgDescr[a], short: Char): String = matchargDescr {case ArgDescr.NoArg(_) => "-${short}"case ArgDescr.ReqArg(_, descr) => "-${short} ${descr}"case ArgDescr.OptArg(_, descr) => "-${short}[${descr}]" }////// Format a named option.///defformatLong(argDescr: ArgDescr[a], long: String): String = matchargDescr {case ArgDescr.NoArg(_) => "--${long}"case ArgDescr.ReqArg(_, descr) => "--${long}=${descr}"case ArgDescr.OptArg(_, descr) => "--${long}[=${descr}]" }////// Decode the list of command line options supplied to the program.////// `ordering` mandates how processing of options and non-options is handled.////// `optDescriptors` is a list of processors for decoding individual options.////// `sourceArgs` should be the list of command line arguments supplied to the program.////// If successful, `getOpt` returns lists of decoded options and non-options. If unsuccessful it/// returns a non-empty list of error messages, with unknown options considered to be errors.///pubdefgetOpt(ordering: ArgOrder[a],optDescriptors: List[OptionDescr[a]],sourceArgs: List[String] ): Validation[String, {options = List[a], nonOptions = List[String]}] =letanswers = getOpt1(ordering, optDescriptors, sourceArgs);matchanswers#errors :::List.map(errUnrec, answers#unknowns) {case Nil => Validation.Success({options = answers#options, nonOptions = answers#nonOptions})casee :: es => matchList.toNec(es) {case None => Validation.Failure(Nec.singleton(e))case Some(c) => Validation.Failure(Nec.cons(e, c)) } }////// This is a more general version of `getOpt` that returns all the results of decoding the command line/// options without distinguishing whether the decoding is successful or a failure.////// Client code calling this function, rather than `getOpt`, is free to process or ignore the results collected/// in `unknowns` and `errors` which would indicate problems with the decoding if either were not empty.///pubdefgetOpt1(ordering: ArgOrder[a],optDescriptors: List[OptionDescr[a]],sourceArgs: List[String] ): {options = List[a], nonOptions = List[String], unknowns = List[String], errors = List[String]} =let (os, xs, us, es) = getOpt1Helper(ordering, optDescriptors, sourceArgs); {options = os, nonOptions = xs, unknowns = us, errors = es}////// Helper for `getOpt1`///defgetOpt1Helper(ordering: ArgOrder[a],optDescrs: List[OptionDescr[a]],sourceArgs: List[String] ): (List[a], List[String], List[String], List[String]) = matchsourceArgs {case Nil => (Nil, Nil, Nil, Nil)casearg :: args => {let (opt, rest) = getNext(arg, optDescrs, args);let (os, xs, us, es) = getOpt1Helper(ordering, optDescrs, rest);match (opt, ordering) {case (OptionKind.Opt(o), _) => (o :: os, xs, us, es)case (OptionKind.UnreqOpt(u), _) => (os, xs, u :: us, es) // u is added tocase (OptionKind.NonOpt(x), ArgOrder.RequireOrder) => (Nil, x :: rest, Nil, Nil)case (OptionKind.NonOpt(x), ArgOrder.Permute) => (os, x :: xs, us, es)case (OptionKind.NonOpt(x), ArgOrder.ReturnInOrder(f)) => (f(x) :: os, xs, us, es)case (OptionKind.EndOfOpts, ArgOrder.RequireOrder) => (Nil, rest, Nil, Nil)case (OptionKind.EndOfOpts, ArgOrder.Permute) => (Nil, rest, Nil, Nil)case (OptionKind.EndOfOpts, ArgOrder.ReturnInOrder(f)) => (List.map(f, rest), Nil, Nil, Nil)case (OptionKind.OptErr(e), _) => (os, xs, us, e :: es) } } }////// Returns the Option cacluated by decoding `arg` and a list of remaining tokens.////// Depending on whether the option decoded for `arg` takes an argument, we might look/// into the token stream for it.///defgetNext(arg: String,optDescrs: List[OptionDescr[a]],tokens: List[String] ): (OptionKind[a], List[String]) = matcharg {case "--" => (OptionKind.EndOfOpts, tokens)casesifString.startsWith({prefix = "--"}, s) => longOpt(String.dropLeft(2, s), optDescrs, tokens)casesifString.startsWith({prefix = "-"}, s) andString.length(s) >=2 => shortOpt(String.charAt(1, s), String.dropLeft(2, s), optDescrs, tokens)cases => (OptionKind.NonOpt(s), tokens) }/// "--" has been parsed/// `xstr` is the rest of the current tokendeflongOpt(xstr: String, optDescrs: List[OptionDescr[a]], tokens: List[String]): (OptionKind[a], List[String]) =let (optname, arg) = String.breakOnLeft({substr = "="}, xstr);letoptStr = "--${optname}";matchfindOptionByName(optname, optDescrs) {case Ok(opt) => match (opt#argDescriptor, deconsLeft(arg), tokens) {case (ArgDescr.NoArg(a), None, rest) => (OptionKind.Opt(a), rest)case (ArgDescr.NoArg(_), Some(('=', _)), rest) => (errNoArgReq(optStr), rest)case (ArgDescr.ReqArg(_, d), None, Nil) => (errReq(d, optStr), Nil)case (ArgDescr.ReqArg(f, _), None, r :: rest) => (decodeArg(f(r), optStr), rest)case (ArgDescr.ReqArg(f, _), Some(('=', ss)), rest) => (decodeArg(f(ss), optStr), rest)case (ArgDescr.OptArg(f, _), None, rest) => (decodeArg(f(None), optStr), rest)case (ArgDescr.OptArg(f, _), Some(('=', ss)), rest) => (decodeArg(f(Some(ss)), optStr), rest)case (_, _, rest) => (OptionKind.UnreqOpt("--${xstr}"), rest) }case Err(GetOptionFailure.Ambiguous(options)) => (errAmbig(options, optStr), tokens)case Err(GetOptionFailure.NotFound) => (OptionKind.UnreqOpt("--${xstr}"), tokens) }/// Returns the "left view" of String `s`.////// If `s` is the empty String, `None` is returned.////// Otherwise return `Some(c, rs)` where `c` is the first character of `s` and `rs` is the rest of `s`.///defdeconsLeft(s: String): Option[(Char, String)] =if (String.length(s) >0) Some((String.charAt(0, s), String.dropLeft(1, s)))else None/// "-" has been parsed (i.e. x0)./// `xc` is the first char of the current token after the dash, which should identify the option./// `xstr` is the rest of the current token/// Never see "=" for a short argdefshortOpt(xc: Char, xstr: String, optDescrs: List[OptionDescr[a]], tokens: List[String]): (OptionKind[a], List[String]) =letoptStr = "-${xc}";matchfindOptionById(xc, optDescrs) {case Ok(opt) => match (opt#argDescriptor, xstr, tokens) {case (ArgDescr.NoArg(a), "", rest) => (OptionKind.Opt(a), rest)case (ArgDescr.NoArg(a), str, rest) => (OptionKind.Opt(a), "-${str}" :: rest)case (ArgDescr.ReqArg(_, d), "", Nil) => (errReq(d, optStr), Nil)case (ArgDescr.ReqArg(f, _), "", r :: rest) => (decodeArg(f(r), optStr), rest)case (ArgDescr.ReqArg(f, _), str, rest) => (decodeArg(f(str), optStr), rest)case (ArgDescr.OptArg(f, _), "", rest) => (decodeArg(f(None), optStr), rest)case (ArgDescr.OptArg(f, _), str, rest) => (decodeArg(f(Some(str)), optStr), rest) }case Err(GetOptionFailure.Ambiguous(options)) => (errAmbig(options, optStr), tokens)case Err(GetOptionFailure.NotFound) ifxstr=="" => (OptionKind.UnreqOpt(optStr), tokens)case Err(GetOptionFailure.NotFound) => (OptionKind.UnreqOpt(optStr), "-${xstr}" :: tokens) }////// Decode the argument which should be `Some(_)` otherwise decoding has failed.///defdecodeArg(x: Option[a], optStr: String): OptionKind[a] = matchx {case Some(x1) => OptionKind.Opt(x1)case None => errDecode(optStr) }/// Looking up options////// A single char or an option name should identify a single option descriptor,/// but if the list of option descriptors is ill-formed (different option descriptors/// share identifiers) or the command line string is ill-formed (i.e. uses an option that/// is not defined) looking up the option will cause a error.///enumGetOptionFailure[a] {case Ambiguous(Nel[OptionDescr[a]])case NotFound }////// Return the option descriptor identified by the String `optname`.////// If `optname` exactly determines a single option descriptor it will be returned, otherwise/// `optname` will be used as a prefix to try to identify a single option descriptor.////// This function returns `Err(NotFound)` if `xc` is not recognized.////// It returns `Err(Ambiguous(xs))` if `xc` identifies more than one option descriptor.///deffindOptionByName(optname: String, optDescrs: List[OptionDescr[a]]): Result[GetOptionFailure[a], OptionDescr[a]] =letgetWith = p -> List.filterMap(o -> { letxs = o#optionNames; if (List.exists(p(optname), xs)) Some(o) else None }, optDescrs);// Exact match of `optname` ...letexact = getWith((x, y) -> x==y);// Otherwise use `optname` as a prefix...letoptions = if (List.isEmpty(exact)) getWith((sub, s1) -> String.startsWith({prefix = sub}, s1)) elseexact;matchoptions {caseoptd :: Nil => Ok(optd)casex :: xs => Err(GetOptionFailure.Ambiguous(Nel.Nel(x, xs)))case Nil => Err(GetOptionFailure.NotFound) }////// Return the option descriptor identified by the Char `xc`.////// This function returns `Err(NotFound)` if `xc` is not recognized.////// It returns `Err(Ambiguous(xs))` if `xc` identifies more than one option descriptor.///deffindOptionById(xc: Char, optDescrs: List[OptionDescr[a]]): Result[GetOptionFailure[a], OptionDescr[a]] =// This finds 0, 1, or more results. "more" is a failure (ambiguous), 0 is ignored, 1 is success.letoptions = List.filterMap(o -> { letss = o#optionIds; if (List.exists(s -> s==xc, ss)) Some(o) else None }, optDescrs);matchoptions {caseoptd :: Nil => Ok(optd)casex :: xs => Err(GetOptionFailure.Ambiguous(Nel.Nel(x, xs)))case Nil => Err(GetOptionFailure.NotFound) }/// Error formatting functions////// Return an error indicating the option is ambiguous and could identify more than one option descriptor.///deferrAmbig(ods: Nel[OptionDescr[a]], optStr: String): OptionKind[a] =letheader = "option '${optStr}' is ambiguous; could be one of:"; OptionKind.OptErr(usageInfo(header, Nel.toList(ods)))////// Return an error indicating the option requires an argument that was not supplied.///deferrReq(d: String, optStr: String): OptionKind[a] = OptionKind.OptErr("option '${optStr}' requires an argument ${d}\n")////// Return an error indicating the option name was not recognized.///deferrUnrec(optStr: String): String = "unrecognized option '${optStr}'\n"////// Return an error indicating the option was supplied an argument when it does not want one.///deferrNoArgReq(optStr: String): OptionKind[a] = OptionKind.OptErr("option '${optStr}' doesn't allow an argument\n")////// Return an error indicating the option was supplied an argument it could not decode.///deferrDecode(optStr: String): OptionKind[a] = OptionKind.OptErr("option '${optStr}' could not decode its argument\n")/// Preprocessing the commandline args.////// Preprocess the command line arguments before parsing them.////// Arguments supplied as an `List[String]` to the program are simply derived from the input split on space./// This does not account for, for example, Windows file names which may include space.////// `preprocess` is a simple function that can be used to "rejoin" command line arguments if they were/// joined by "quotation marks" in the user supplied string (quotations marks are configurable and do not/// have to be double quotes).///pubdefpreprocess(options: {quoteOpen = String, quoteClose = String, stripQuoteMarks = Bool}, args: List[String]): List[String] =letargs1 = groupWhenQuoted(options#quoteOpen, options#quoteClose, args);if (options#stripQuoteMarks)removeQuoteMarks(options#quoteOpen, options#quoteClose, args1)elseargs1////// Returns `args` with tokens inside quotes grouped together as a single token.////// Starts in the initial state, outside a quotation.///defgroupWhenQuoted(quoteOpen: String, quoteClose: String, args: List[String]): List[String] =groupWhenQuotedHelper(quoteOpen, quoteClose, args, ks -> ks)////// Helper for `groupWhenQuoted`.////// Here we are in the initial state, outside a quotation.///defgroupWhenQuotedHelper(quoteOpen: String, quoteClose: String, tokens: List[String], k: List[String] -> List[String] \ ef): List[String] \ ef =matchtokens {case Nil => k(Nil)casex :: restifString.contains({substr = quoteOpen}, x) andString.endsWith({suffix = quoteClose}, x) => groupWhenQuotedHelper(quoteOpen, quoteClose, rest, ks -> k(x :: ks))casex :: restifString.contains({substr = quoteOpen}, x) => groupWhenQuotedHelperInner(quoteOpen, quoteClose, rest, x, ks -> k(ks))casex :: rest => groupWhenQuotedHelper(quoteOpen, quoteClose, rest, ks -> k(x :: ks)) }////// Helper for `groupWhenQuoted`.////// Here we are "inside" a quotation, stored so far as `acc`.///defgroupWhenQuotedHelperInner(quoteOpen: String, quoteClose: String, tokens: List[String], acc: String, k: List[String] -> List[String] \ ef): List[String] \ ef =matchtokens {case Nil => k(acc :: Nil)casex :: restifString.contains({substr = quoteClose}, x) => groupWhenQuotedHelper(quoteOpen, quoteClose, rest, ks -> k((acc+" "+x) :: ks))casex :: rest => groupWhenQuotedHelperInner(quoteOpen, quoteClose, rest, (acc+" "+x), k) }////// Remove all quote marks from the list of tokens.///defremoveQuoteMarks(quoteOpen: String, quoteClose: String, args: List[String]): List[String] =args |> List.map(String.replace({src = quoteOpen}, {dst = ""}) >> String.replace({src = quoteClose}, {dst = ""}))}