Skip to content

Commit

Permalink
Add GetoptMode parser setting and implementation
Browse files Browse the repository at this point in the history
Turning on Getopt mode automatically turns on the EnableDashDash and
AllowMultiInstance settings as well, but they can be disabled by
explicitly setting them to false in the parser settings.
  • Loading branch information
rmunn committed Aug 19, 2020
1 parent 3354ffb commit 570d7b7
Show file tree
Hide file tree
Showing 9 changed files with 720 additions and 11 deletions.
219 changes: 219 additions & 0 deletions src/CommandLine/Core/GetoptTokenizer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
// Copyright 2005-2015 Giacomo Stelluti Scala & Contributors. All rights reserved. See License.md in the project root for license information.

using System;
using System.Collections.Generic;
using System.Linq;
using CommandLine.Infrastructure;
using CSharpx;
using RailwaySharp.ErrorHandling;
using System.Text.RegularExpressions;

namespace CommandLine.Core
{
static class GetoptTokenizer
{
public static Result<IEnumerable<Token>, Error> Tokenize(
IEnumerable<string> arguments,
Func<string, NameLookupResult> nameLookup)
{
return GetoptTokenizer.Tokenize(arguments, nameLookup, ignoreUnknownArguments:false, allowDashDash:true, posixlyCorrect:false);
}

public static Result<IEnumerable<Token>, Error> Tokenize(
IEnumerable<string> arguments,
Func<string, NameLookupResult> nameLookup,
bool ignoreUnknownArguments,
bool allowDashDash,
bool posixlyCorrect)
{
var errors = new List<Error>();
Action<string> onBadFormatToken = arg => errors.Add(new BadFormatTokenError(arg));
Action<string> unknownOptionError = name => errors.Add(new UnknownOptionError(name));
Action<string> doNothing = name => {};
Action<string> onUnknownOption = ignoreUnknownArguments ? doNothing : unknownOptionError;

int consumeNext = 0;
Action<int> onConsumeNext = (n => consumeNext = consumeNext + n);
bool forceValues = false;

var tokens = new List<Token>();

var enumerator = arguments.GetEnumerator();
while (enumerator.MoveNext())
{
switch (enumerator.Current) {
case null:
break;

case string arg when forceValues:
tokens.Add(Token.ValueForced(arg));
break;

case string arg when consumeNext > 0:
tokens.Add(Token.Value(arg));
consumeNext = consumeNext - 1;
break;

case "--" when allowDashDash:
forceValues = true;
break;

case "--":
tokens.Add(Token.Value("--"));
if (posixlyCorrect) forceValues = true;
break;

case "-":
// A single hyphen is always a value (it usually means "read from stdin" or "write to stdout")
tokens.Add(Token.Value("-"));
if (posixlyCorrect) forceValues = true;
break;

case string arg when arg.StartsWith("--"):
tokens.AddRange(TokenizeLongName(arg, nameLookup, onBadFormatToken, onUnknownOption, onConsumeNext));
break;

case string arg when arg.StartsWith("-"):
tokens.AddRange(TokenizeShortName(arg, nameLookup, onUnknownOption, onConsumeNext));
break;

case string arg:
// If we get this far, it's a plain value
tokens.Add(Token.Value(arg));
if (posixlyCorrect) forceValues = true;
break;
}
}

return Result.Succeed<IEnumerable<Token>, Error>(tokens.AsEnumerable(), errors.AsEnumerable());
}

public static Result<IEnumerable<Token>, Error> ExplodeOptionList(
Result<IEnumerable<Token>, Error> tokenizerResult,
Func<string, Maybe<char>> optionSequenceWithSeparatorLookup)
{
var tokens = tokenizerResult.SucceededWith().Memoize();

var replaces = tokens.Select((t, i) =>
optionSequenceWithSeparatorLookup(t.Text)
.MapValueOrDefault(sep => Tuple.Create(i + 1, sep),
Tuple.Create(-1, '\0'))).SkipWhile(x => x.Item1 < 0).Memoize();

var exploded = tokens.Select((t, i) =>
replaces.FirstOrDefault(x => x.Item1 == i).ToMaybe()
.MapValueOrDefault(r => t.Text.Split(r.Item2).Select(Token.Value),
Enumerable.Empty<Token>().Concat(new[] { t })));

var flattened = exploded.SelectMany(x => x);

return Result.Succeed(flattened, tokenizerResult.SuccessMessages());
}

public static Func<
IEnumerable<string>,
IEnumerable<OptionSpecification>,
Result<IEnumerable<Token>, Error>>
ConfigureTokenizer(
StringComparer nameComparer,
bool ignoreUnknownArguments,
bool enableDashDash,
bool posixlyCorrect)
{
return (arguments, optionSpecs) =>
{
var tokens = GetoptTokenizer.Tokenize(arguments, name => NameLookup.Contains(name, optionSpecs, nameComparer), ignoreUnknownArguments, enableDashDash, posixlyCorrect);
var explodedTokens = GetoptTokenizer.ExplodeOptionList(tokens, name => NameLookup.HavingSeparator(name, optionSpecs, nameComparer));
return explodedTokens;
};
}

private static IEnumerable<Token> TokenizeShortName(
string arg,
Func<string, NameLookupResult> nameLookup,
Action<string> onUnknownOption,
Action<int> onConsumeNext)
{

// First option char that requires a value means we swallow the rest of the string as the value
// But if there is no rest of the string, then instead we swallow the next argument
string chars = arg.Substring(1);
int len = chars.Length;
if (len > 0 && Char.IsDigit(chars[0]))
{
// Assume it's a negative number
yield return Token.Value(arg);
yield break;
}
for (int i = 0; i < len; i++)
{
var s = new String(chars[i], 1);
switch(nameLookup(s))
{
case NameLookupResult.OtherOptionFound:
yield return Token.Name(s);

if (i+1 < len)
{
// Rest of this is the value (e.g. "-sfoo" where "-s" is a string-consuming arg)
yield return Token.Value(chars.Substring(i+1));
yield break;
}
else
{
// Value is in next param (e.g., "-s foo")
onConsumeNext(1);
}
break;

case NameLookupResult.NoOptionFound:
onUnknownOption(s);
break;

default:
yield return Token.Name(s);
break;
}
}
}

private static IEnumerable<Token> TokenizeLongName(
string arg,
Func<string, NameLookupResult> nameLookup,
Action<string> onBadFormatToken,
Action<string> onUnknownOption,
Action<int> onConsumeNext)
{
string[] parts = arg.Substring(2).Split(new char[] { '=' }, 2);
string name = parts[0];
string value = (parts.Length > 1) ? parts[1] : null;
// A parameter like "--stringvalue=" is acceptable, and makes stringvalue be the empty string
if (String.IsNullOrWhiteSpace(name) || name.Contains(" "))
{
onBadFormatToken(arg);
yield break;
}
switch(nameLookup(name))
{
case NameLookupResult.NoOptionFound:
onUnknownOption(name);
yield break;

case NameLookupResult.OtherOptionFound:
yield return Token.Name(name);
if (value == null) // NOT String.IsNullOrEmpty
{
onConsumeNext(1);
}
else
{
yield return Token.Value(value);
}
break;

default:
yield return Token.Name(name);
break;
}
}
}
}
20 changes: 19 additions & 1 deletion src/CommandLine/Infrastructure/StringExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,5 +73,23 @@ public static bool ToBoolean(this string value)
{
return value.Equals("true", StringComparison.OrdinalIgnoreCase);
}

public static bool ToBooleanLoose(this string value)
{
if ((string.IsNullOrEmpty(value)) ||
(value == "0") ||
(value.Equals("f", StringComparison.OrdinalIgnoreCase)) ||
(value.Equals("n", StringComparison.OrdinalIgnoreCase)) ||
(value.Equals("no", StringComparison.OrdinalIgnoreCase)) ||
(value.Equals("off", StringComparison.OrdinalIgnoreCase)) ||
(value.Equals("false", StringComparison.OrdinalIgnoreCase)))
{
return false;
}
else
{
return true;
}
}
}
}
}
9 changes: 7 additions & 2 deletions src/CommandLine/Parser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,13 @@ private static Result<IEnumerable<Token>, Error> Tokenize(
IEnumerable<OptionSpecification> optionSpecs,
ParserSettings settings)
{
return
Tokenizer.ConfigureTokenizer(
return settings.GetoptMode
? GetoptTokenizer.ConfigureTokenizer(
settings.NameComparer,
settings.IgnoreUnknownArguments,
settings.EnableDashDash,
settings.PosixlyCorrect)(arguments, optionSpecs)
: Tokenizer.ConfigureTokenizer(
settings.NameComparer,
settings.IgnoreUnknownArguments,
settings.EnableDashDash)(arguments, optionSpecs);
Expand Down
40 changes: 34 additions & 6 deletions src/CommandLine/ParserSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.IO;

using CommandLine.Infrastructure;
using CSharpx;

namespace CommandLine
{
Expand All @@ -23,9 +24,11 @@ public class ParserSettings : IDisposable
private bool autoHelp;
private bool autoVersion;
private CultureInfo parsingCulture;
private bool enableDashDash;
private Maybe<bool> enableDashDash;
private int maximumDisplayWidth;
private bool allowMultiInstance;
private Maybe<bool> allowMultiInstance;
private bool getoptMode;
private Maybe<bool> posixlyCorrect;

/// <summary>
/// Initializes a new instance of the <see cref="ParserSettings"/> class.
Expand All @@ -38,6 +41,10 @@ public ParserSettings()
autoVersion = true;
parsingCulture = CultureInfo.InvariantCulture;
maximumDisplayWidth = GetWindowWidth();
getoptMode = false;
enableDashDash = Maybe.Nothing<bool>();
allowMultiInstance = Maybe.Nothing<bool>();
posixlyCorrect = Maybe.Nothing<bool>();
}

private int GetWindowWidth()
Expand Down Expand Up @@ -159,11 +166,12 @@ public bool AutoVersion
/// <summary>
/// Gets or sets a value indicating whether enable double dash '--' syntax,
/// that forces parsing of all subsequent tokens as values.
/// If GetoptMode is true, this defaults to true, but can be turned off by explicitly specifying EnableDashDash = false.
/// </summary>
public bool EnableDashDash
{
get { return enableDashDash; }
set { PopsicleSetter.Set(Consumed, ref enableDashDash, value); }
get => enableDashDash.MatchJust(out bool value) ? value : getoptMode;
set => PopsicleSetter.Set(Consumed, ref enableDashDash, Maybe.Just(value));
}

/// <summary>
Expand All @@ -177,11 +185,31 @@ public int MaximumDisplayWidth

/// <summary>
/// Gets or sets a value indicating whether options are allowed to be specified multiple times.
/// If GetoptMode is true, this defaults to true, but can be turned off by explicitly specifying AllowMultiInstance = false.
/// </summary>
public bool AllowMultiInstance
{
get => allowMultiInstance;
set => PopsicleSetter.Set(Consumed, ref allowMultiInstance, value);
get => allowMultiInstance.MatchJust(out bool value) ? value : getoptMode;
set => PopsicleSetter.Set(Consumed, ref allowMultiInstance, Maybe.Just(value));
}

/// <summary>
/// Whether strict getopt-like processing is applied to option values; if true, AllowMultiInstance and EnableDashDash will default to true as well.
/// </summary>
public bool GetoptMode
{
get => getoptMode;
set => PopsicleSetter.Set(Consumed, ref getoptMode, value);
}

/// <summary>
/// Whether getopt-like processing should follow the POSIX rules (the equivalent of using the "+" prefix in the C getopt() call).
/// If not explicitly set, will default to false unless the POSIXLY_CORRECT environment variable is set, in which case it will default to true.
/// </summary>
public bool PosixlyCorrect
{
get => posixlyCorrect.MapValueOrDefault(val => val, () => Environment.GetEnvironmentVariable("POSIXLY_CORRECT").ToBooleanLoose());
set => PopsicleSetter.Set(Consumed, ref posixlyCorrect, Maybe.Just(value));
}

internal StringComparer NameComparer
Expand Down
27 changes: 27 additions & 0 deletions tests/CommandLine.Tests/Fakes/Simple_Options_With_ExtraArgs.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright 2005-2015 Giacomo Stelluti Scala & Contributors. All rights reserved. See License.md in the project root for license information.

using System.Collections.Generic;

namespace CommandLine.Tests.Fakes
{
public class Simple_Options_WithExtraArgs
{
[Option(HelpText = "Define a string value here.")]
public string StringValue { get; set; }

[Option('s', "shortandlong", HelpText = "Example with both short and long name.")]
public string ShortAndLong { get; set; }

[Option('i', Min = 3, Max = 4, Separator = ',', HelpText = "Define a int sequence here.")]
public IEnumerable<int> IntSequence { get; set; }

[Option('x', HelpText = "Define a boolean or switch value here.")]
public bool BoolValue { get; set; }

[Value(0, HelpText = "Define a long value here.")]
public long LongValue { get; set; }

[Value(1, HelpText = "Extra args get collected here.")]
public IEnumerable<string> ExtraArgs { get; set; }
}
}
Loading

0 comments on commit 570d7b7

Please sign in to comment.