Skip to content

Add ability to parse command line switches without a value #43481

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 9 commits into from
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,23 @@ public static class CommandLineConfigurationExtensions
/// <para>
/// The values passed on the command line, in the <c>args</c> string array, should be a set
/// of keys prefixed with two dashes ("--") and then values, separate by either the
/// equals sign ("=") or a space (" ").
/// equals sign ("=") or a space (" "). If a value is omitted, the key is presumed to be
/// a simple switch with the value "true" when evaluated.
/// </para>
/// <para>
/// A forward slash ("/") can be used as an alternative prefix, with either equals or space, and when using
/// an equals sign the prefix can be left out altogether.
/// </para>
/// <para>
/// There are five basic alternative formats for arguments:
/// <c>key1=value1 --key2=value2 /key3=value3 --key4 value4 /key5 value5</c>.
/// There are eight basic alternative formats for arguments:
/// <c>key1=value1 --key2=value2 /key3=value3 --key4 value4 /key5 value5 -key6 --key7 /key8</c>.
/// The last three evaluate to the value "true".
/// </para>
/// </remarks>
/// <example>
/// A simple console application that has five values.
/// A simple console application that has eight values.
/// <code>
/// // dotnet run key1=value1 --key2=value2 /key3=value3 --key4 value4 /key5 value5
/// // dotnet run key1=value1 --key2=value2 /key3=value3 --key4 value4 /key5 value5 -key6 --key7 /key8
///
/// using Microsoft.Extensions.Configuration;
/// using System;
Expand All @@ -58,6 +60,9 @@ public static class CommandLineConfigurationExtensions
/// Console.WriteLine($"Key3: '{config["Key3"]}'");
/// Console.WriteLine($"Key4: '{config["Key4"]}'");
/// Console.WriteLine($"Key5: '{config["Key5"]}'");
/// Console.WriteLine($"Key6: '{config["Key6"]}'");
/// Console.WriteLine($"Key7: '{config["Key7"]}'");
/// Console.WriteLine($"Key8: '{config["Key8"]}'");
/// }
/// }
/// }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

using System;
using System.Collections.Generic;
#if !NET461
using System.Runtime.InteropServices;
#endif

namespace Microsoft.Extensions.Configuration.CommandLine
{
Expand All @@ -12,6 +15,12 @@ namespace Microsoft.Extensions.Configuration.CommandLine
public class CommandLineConfigurationProvider : ConfigurationProvider
{
private readonly Dictionary<string, string> _switchMappings;
private static bool s_isWindows =
#if NET461
true;
#else
RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
#endif

/// <summary>
/// Initializes a new instance.
Expand All @@ -31,104 +40,171 @@ public CommandLineConfigurationProvider(IEnumerable<string> args, IDictionary<st
/// <summary>
/// The command line arguments.
/// </summary>
protected IEnumerable<string> Args { get; private set; }
protected IEnumerable<string> Args { get; }

/// <summary>
/// Loads the configuration data from the command line args.
/// </summary>
public override void Load()
{
var data = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
string key, value;

using (IEnumerator<string> enumerator = Args.GetEnumerator())
{
// Store 1st argument here and start while loop with the 2nd,
// or 'Current', argument. This is so we can look at the
// 'Current' argument in relation to the 1st while evaluating
// the 1st.
string previousArg = enumerator.MoveNext() ? enumerator.Current : null;

// If no first arg, return empty dictionary
if (previousArg == null)
{
Data = data;
return;
}

while (enumerator.MoveNext())
{
// 'Current' is now the 2nd argument in Args
string currentArg = enumerator.Current;
int keyStartIndex = 0;

if (currentArg.StartsWith("--"))
{
keyStartIndex = 2;
}
else if (currentArg.StartsWith("-"))
// TryProcessArgs will return false if previousArg is invalid
if (TryProcessArgs(previousArg, currentArg, out (string key, string value) loopPair))
{
keyStartIndex = 1;
}
else if (currentArg.StartsWith("/"))
{
// "/SomeSwitch" is equivalent to "--SomeSwitch" when interpreting switch mappings
// So we do a conversion to simplify later processing
currentArg = string.Format("--{0}", currentArg.Substring(1));
keyStartIndex = 2;
// Override value when key is duplicated,
// so we always have the last argument win.
data[loopPair.key] = loopPair.value;
}
previousArg = currentArg;
}

int separator = currentArg.IndexOf('=');
// Process the last previousArg after exiting loop
if (TryProcessArgs(previousArg, null, out (string key, string value) lastPair))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't this be calling TryProcessArgs with the last arg twice? previousArg will be pointing to enumerator.Current which is the same as currentArg if it entered the while loop.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable previousArg gets assigned the value of currentArg at the end of the last loop in the while block. When it reaches the point where it processes the last argument (outside the while loop), previousArg and currentArg are indeed the same value, but that value has not yet been passed as the previousArg to the TryProcessArgs method. It only goes through it once.

{
data[lastPair.key] = lastPair.value;
}
}

if (separator < 0)
{
// If there is neither equal sign nor prefix in current arugment, it is an invalid format
if (keyStartIndex == 0)
{
// Ignore invalid formats
continue;
}

// If the switch is a key in given switch mappings, interpret it
if (_switchMappings != null && _switchMappings.TryGetValue(currentArg, out string mappedKey))
{
key = mappedKey;
}
// If the switch starts with a single "-" and it isn't in given mappings , it is an invalid usage so ignore it
else if (keyStartIndex == 1)
{
continue;
}
// Otherwise, use the switch name directly as a key
else
{
key = currentArg.Substring(keyStartIndex);
}

string previousKey = enumerator.Current;
if (!enumerator.MoveNext())
{
// ignore missing values
continue;
}

value = enumerator.Current;
}
else
{
string keySegment = currentArg.Substring(0, separator);

// If the switch is a key in given switch mappings, interpret it
if (_switchMappings != null && _switchMappings.TryGetValue(keySegment, out string mappedKeySegment))
{
key = mappedKeySegment;
}
// If the switch starts with a single "-" and it isn't in given mappings , it is an invalid usage
else if (keyStartIndex == 1)
{
throw new FormatException(SR.Format(SR.Error_ShortSwitchNotDefined, currentArg));
}
// Otherwise, use the switch name directly as a key
else
{
key = currentArg.Substring(keyStartIndex, separator - keyStartIndex);
}

value = currentArg.Substring(separator + 1);
}
Data = data;
}

/// <summary>
/// Reconcile two consecutive arguments into a key-value pair for the first.
/// </summary>
/// <remarks>
/// Helper function to reduce repeated code in <see cref="Load"/> method.
/// </remarks>
/// <param name="previousArg">The first string argument</param>
/// <param name="currentArg">The second string argument</param>
/// <param name="pair">
/// A properly resolved configuration key-value pair, or null if previous argument is invalid.
/// </param>
/// <returns>
/// True if the args can be resolved to a proper configuration key-value pair.
/// </returns>
private bool TryProcessArgs(string previousArg, string currentArg, out (string key, string value) pair)
{
string key, value;
int keyStartIndex = 0;

// Override value when key is duplicated. So we always have the last argument win.
data[key] = value;
if (previousArg.StartsWith("--"))
{
keyStartIndex = 2;
}
else if (previousArg.StartsWith("-"))
{
keyStartIndex = 1;
}
else if (previousArg.StartsWith("/")
&& s_isWindows
&& previousArg.IndexOf("/", StringComparison.Ordinal)
== previousArg.LastIndexOf("/", StringComparison.Ordinal)) // i.e. only one instance of '/'
{
// On Windows, "/SomeSwitch" is equivalent to "--SomeSwitch" when interpreting switch mappings
// So we do a conversion to simplify later processing
previousArg = $"--{previousArg.Substring(1)}";
keyStartIndex = 2;
}

int separatorIndex = previousArg.IndexOf('=');

if (separatorIndex < 0)
{
// If there is neither equal sign nor prefix in previous argument, it is an invalid format
if (keyStartIndex == 0)
{
// Ignore invalid formats
pair = default;
return false;
}

// If the switch is a key in given switch mappings, interpret it
if (_switchMappings != null
&& _switchMappings.TryGetValue(
previousArg,
out string mappedKey))
{
key = mappedKey;
}
// If the switch starts with a single "-" and it isn't in given mappings,
// or in any other case, use the switch name directly as a key
else
{
key = previousArg.Substring(keyStartIndex);
}

// If the argument is last in list, the next argument begins
// with an arg delimiter, or the next argument contains '=',
// then treat argument as switch and record value of "true"
if (currentArg == null
|| currentArg.StartsWith("--")
|| currentArg.StartsWith("-")
|| currentArg.StartsWith("/")
&& s_isWindows
&& currentArg.IndexOf("/", StringComparison.Ordinal)
== currentArg.LastIndexOf("/", StringComparison.Ordinal)
|| currentArg.Contains("="))
{
value = "true";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what would happen if the value expected for the key is not a boolean? Do we have validation for that?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand the question. The value here gets assigned "true" if the next command line argument found is another key. For example:

./program.exe -Key1 --Key2 someValue /Key3 Key4=42

would generate the dictionary { {"Key1", "true"}, {"Key2", "someValue"}, {"Key3", "true"}, {"Key1", "42"} }.

The absence of a switch without a value can be interpreted as a falsy value for that switch. For example, if my program is looking for a value for Key1 in IConfiguration to determine whether or not to enable a feature, and I removed "Key1" from the execution arguments, I would simply not enable that feature if it were missing, much like a switch on a typical *nix program.

Without a given value, true is the best guess. However, I could assign it pretty much any string value I want. I just picked "true" since it seemed the most intuitive. Consumers of this library can still bind the configuration to a POCO, much like appsettings.json configuration, and {"Key1", "true"} in the IConfiguration would be successfully parsed and assigned to something like public bool Key1 { get; set; }.

}
else
{
value = currentArg;
}
}
else
{
string keySegment = previousArg.Substring(0, separatorIndex);

Data = data;
// If the switch is a key in given switch mappings, interpret it
if (_switchMappings != null
&& _switchMappings.TryGetValue(
keySegment,
out string mappedKeySegment))
{
key = mappedKeySegment;
}
// If the switch starts with a single "-" and it isn't in given mappings , it is an invalid usage
else if (keyStartIndex == 1)
{
throw new FormatException(
SR.Format(
SR.Error_ShortSwitchNotDefined,
previousArg));
}
// Otherwise, use the switch name directly as a key
else
{
key = previousArg.Substring(
keyStartIndex,
separatorIndex - keyStartIndex);
}

value = previousArg.Substring(separatorIndex + 1);
}

pair = (key, value);
return true;
}

private Dictionary<string, string> GetValidatedSwitchMappingsCopy(IDictionary<string, string> switchMappings)
Expand Down
Loading